1. 程式人生 > >微信公眾號支付/退款(java環境)開發介紹

微信公眾號支付/退款(java環境)開發介紹

開發之前翻閱了很多帖子,結合自己的實際開發情況,將微信支付/退款 流程以及code貼出,希望通過這一篇帖子就能解決你的問題,有不清楚的直接留言,我會及時回覆(ง •̀_•́)ง得意

 

一些說明:xxxUtils為工具類,Constant為常量類

為方便開發,所用和微信支付相關code(包括工具類)文中均貼出。專案採用的是SSM框架,maven進行管理的

一、開發前準備

 

1.微信官方要求域名必須通過icp備案,且連線方式從2018.01.01起不再支援HTTP連線,僅支援HTTPS

所以需要:1.在icp備案官網或第三方網站申請域名備案2.申請SSL證書從而獲得HTTPS連線,推薦在騰訊雲上申請免費版,有效期1年

https://cloud.tencent.com/product/ssl?from=qcloudHpHeaderSsl

對於不同伺服器對應不同的證書格式以及方法可以參考下面連線 

https://www.wosign.com/support/ssl-install-index.htm

以tomcat伺服器為例:

修改server.xml將預設的localhost訪問修改為https+域名訪問

 

 

以下幾點一開始可能一頭霧水,不知道該如何配置,可以先放一放,等開發到相應步驟自然需要填寫

需要配置的2個平臺地址

商戶平臺:https://pay.weixin.qq.com/

公眾平臺:https://mp.weixin.qq.com/

2.配置微信支付目錄

參考官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=7_3

假設呼叫微信H5支付頁面的地址為https://a.b.com/pay

那麼:授權目錄配置為https://a.b.com/

 

3.配置微信公眾號域名(業務域名、JS介面安全域名、網頁授權域名)

參考官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=7_3

 

4.API安全金鑰

支付簽名所需要拼接的引數,即後文sign拼接所需要的key

參考官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3

 

5.下載商戶證書

參考官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3

退款需要呼叫商戶證書進行驗證

Java環境採用apiclient_cert.p12 證書密碼為商戶號

 

二、開發流程

 

 

其中紅色部分是需要我們開發的地方,其餘部分均為微信功能

 

具體流程:使用者點選支付按鈕-->後臺邏輯處理-->前臺接收資料並呼叫微信JS喚起支付控制元件-->出現輸入密碼介面,包含金額等一些資訊-->輸入密碼後出現微信的支付成功頁面(微信自己處理)-->回撥我們設定的商戶介面(同時後臺也會通知我們支付結果)

我們所需要做的事情:

1.獲取使用者授權,拿到openId

2.呼叫微信統一下單介面獲取預支付id

3.將資料傳送給前臺,呼叫微信內建JS喚起支付控制元件

4.支付完成後,微信回撥URL的處理

5.微信後臺非同步通知商戶支付結果,商戶收到訊息後需要告知微信處理結果

6.根據功能需求的其他業務邏輯,比如DB的互動之類

 

三、具體開發步驟

 

1.獲取使用者授權,拿到openId

參考官方文件:https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421140842

這篇官方文件介紹的還是比較詳細的,可以仔細研究下

大概流程如下:

第一步:使用者同意授權,獲取code

訪問如下連結:

https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect

每個引數的具體含義參照上方官方文件,其中幾個注意點:微信支付情景下scope設定為snsapi_base即可,可以獲得openId;redirect_uri需要urlEncode處理。跳轉回調redirect_uri,應當使用https連結來確保授權code的安全性。  

引數順序必須正確。

如果使用者同意授權,頁面將跳轉至redirect_uri/?code=CODE&state=STATE

redirect_uri一般為controller,拿到code後在其中做後續步驟,如wxpay.xxx.com/wechat/unifiedOrder

code說明: code作為換取access_token的票據,每次使用者授權帶上的code將不一樣,code只能使用一次,5分鐘未被使用自動過期。

 

第二步:通過code換取網頁授權access_token

在 wxpay.xxx.com/wechat/unifiedOrder  Controller中做後續邏輯

String code = request.getParameter("code");

獲取code後,請求以下連結獲取access_token:

https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code

返回資料為JSON,具體含義參照官方文件

我這裡採用了jackson工具將json轉化為實體類進行操作,程式碼如下:

獲取返回資料的工具類:

public static AuthToken getTokenByAuthCode(String code) {
		AuthToken authToken = null;
		StringBuilder json = new StringBuilder();
		try {
			URL url = new URL(Constant.Authtoken_URL(code));
			URLConnection urlConnection = url.openConnection();
			urlConnection.connect();
			BufferedReader in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
			String inputLine;
			while ((inputLine = in.readLine()) != null) {
				json.append(inputLine);
			}
			in.close();
			// 將json文字轉化為authToken物件
			authToken = jsonToEntity(json.toString(), AuthToken.class);
		} catch (IOException e) {
			logger.error("*****獲取access_token異常*****");
			e.printStackTrace();
		}
		return authToken;
	}

Json轉化實體類工具類:

public static <T> T jsonToEntity(String jsonString, Class<T> entityType) {
		T entity = null;
		try {
			entity = jsonObjectMapper.readValue(jsonString, entityType);
		} catch (Exception e) {
			logger.error("*****json轉化異常*****");
			e.printStackTrace();
		}
		return entity;
	}

 

AuthToken是返回資料的實體類

 

2.呼叫微信統一下單介面獲取預支付id

簡單的理解就是呼叫一個微信的API介面,它需要很多的引數,賦值拼接後轉化為XML格式傳送給微信,微信再返回我們XML格式的響應報文。

由於引數很多並且複雜,開發前一定要詳讀官方API文件。

官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1

下面對用到的欄位做具體的講解(注意大小寫!):

appid==公眾賬號ID==微信公眾號後臺檢視

mch_id==商戶號==微信支付平臺檢視

device_info==裝置號==公眾號支付傳“WEB”

nonce_str==32位隨機字串==微信支付API介面協議中包含欄位nonce_str,主要保證簽名不可預測。生成code如下:

public static String generateUUID() {
		return UUID.randomUUID().toString().replace("-", "").substring(0, 32);
	}

sign==簽名==先跳過,等其他引數賦值結束後再講sign

sign_type==簽名型別==採用“MD5”

body==商品描述==傳中文可能出現問題,注意UTF-8編碼

attach==附加資料==在查詢API和支付通知中原樣返回,可作為自定義引數使用

out_trade_no==商戶訂單號==商戶系統內部訂單號,要求32個字元內,只能是數字、大小寫字母_-|*@,且在同一個商戶號下唯一。

我採用的是當前14位系統時間+4位隨機數構成訂單號,程式碼如下:

 

/**
	 * 生成訂單號 yyyyMMddHHmmss+4位隨機數 共18位
	 * 適用於訂單號和退款單號
	 */
	public static String generateOut_trade_no() {
		Date date = new Date();
		SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
		String format = sdf.format(date);
		Random random = new Random();
		String result = "";
		for (int i = 0; i < 4; i++) {
			result += random.nextInt(10);
		}
		String finalResult = format + result;
		logger.info("訂單號:" + finalResult);
		return finalResult;
	}

fee_type==標價幣種==預設“CNY”,可以不傳

total_fee==標價金額==單位為分!!注意做元分轉化

spbill_create_ip==終端ip==springmvc中可以採用request.getRemoteAddr()獲得

time_start==交易起始時間==訂單生成時間,格式為yyyyMMddHHmmss

time_expire==交易結束時間==訂單失效時間,格式為yyyyMMddHHmmss,最短失效時間要超過1分鐘

notify_url==通知地址==非同步接收微信支付結果通知的回撥地址,通知url必須為外網可訪問的url,不能攜帶引數。(現在可能不知道做什麼用的,也不知道怎麼配,沒關係,等做到後面微信通知結果就豁然開朗了,可以先隨便賦值)

trade_type==交易型別==公眾號支付傳“JSAPI

openid==使用者標識==上一步獲得的openid

微信簽名演算法詳解:(提示簽名錯誤很正常,仔細檢查拼接順序,大小寫)

參考文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3

第一步,設所有傳送或者接收到的資料為集合M,將集合M內非空引數值的引數按照引數名ASCII碼從小到大排序(字典序),使用URL鍵值對的格式(即key1=value1&key2=value2…)拼接成字串stringA

第二步,在stringA最後拼接上key得到stringSignTemp字串,並對stringSignTemp進行MD5運算,再將得到的字串所有字元轉換為大寫,得到sign值signValue

得到sign後賦值,並將所有引數封裝成XML

 

上圖就是將微信所需要的所有引數賦值並放入實體類(除sign外),然後將該實體類轉化為map並通過工具類生成簽名,具體code如下:

//實體類轉化為SortedMap
	private SortedMap<String, Object> buildParamMap(PaySendData data) {
		SortedMap<String, Object> paramters = new TreeMap<String, Object>();
		Field[] fields = data.getClass().getDeclaredFields();
		try {
			for (Field field : fields) {
				field.setAccessible(true);
				if (null != field.get(data)) {
					paramters.put(field.getName().toLowerCase(), field.get(data).toString());
				}
			}
		} catch (Exception e) {
			logger.error("構建簽名map錯誤: ");
			e.printStackTrace();
		}
		return paramters;
	}

獲得簽名

 

public static String getSign(SortedMap<String, Object> map) {
		StringBuffer buffer = new StringBuffer();
		Set<Map.Entry<String, Object>> set = map.entrySet();
		Iterator<Map.Entry<String, Object>> iterator = set.iterator();
		while (iterator.hasNext()) {
			Map.Entry<String, Object> entry = iterator.next();
			String k = entry.getKey();
			Object v = entry.getValue();
			// 引數中sign、key不參與簽名加密
			if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) {
				buffer.append(k + "=" + v + "&");
			}
		}
		buffer.append("key=" + Constant.KEY);
		//System.out.println(buffer.toString());
		String sign = MD5(buffer.toString()).toUpperCase();
		logger.info("sign:" + sign);
		return sign;
	}

現在所有引數+簽名都已經獲得並注入實體類,封裝成XML,採用XStream

由於XStream本身不支援帶有“_”的節點,而微信引數中帶有“_”,所以首先要讓其支援

 

public static XStream xStream = new XStream(new DomDriver("UTF-8", new XmlFriendlyNameCoder("-_", "_")));

之後使用xStream物件做序列化操作

public static String sendDataToXml(PaySendData data) {
		xStream.autodetectAnnotations(true);
		xStream.alias("xml", PaySendData.class);
		String xmlData = xStream.toXML(data);
		logger.info(xmlData);
		return xmlData;
	}

PS:在PaySendData實體類中需要使用@XStreamAlias("xxx")註解,xxx即想要序列化後的名字

 

到此,得到了微信所需要的XML封裝好的引數,下面呼叫微信的統一下單地址:https://api.mch.weixin.qq.com/pay/unifiedorder

這裡採用apache的httpclient進行連線

try {
			// 傳送POST統一下單請求
			CloseableHttpResponse response = HttpUtil.Post(Constant.UNIFIED_ORDER_URL, reqXml, false);
			try {
				resultMap = PayUtils.parseXml(response.getEntity().getContent());
				//TODO 最終刪除
				logger.info(resultMap.toString());
				// 關閉流
				EntityUtils.consume(response.getEntity());
			} finally {
				response.close();
			}
		} catch (Exception e) {
			logger.error("*****微信支付統一下單異常*****");
			e.printStackTrace();
		}

xml格式的流轉化為map集合

public static Map<String, Object> parseXml(InputStream inputStream) {
		SortedMap<String, Object> map = new TreeMap<String, Object>();
		try {
			// 獲取request輸入流
			SAXReader reader = new SAXReader();
			Document document = reader.read(inputStream);
			// 得到xml根元素
			Element root = document.getRootElement();
			// 得到根元素所有節點
			List<Element> elementList = root.elements();
			// 遍歷所有子節點
			for (Element element : elementList) {
				map.put(element.getName(), element.getText());
			}
			// 釋放資源
			inputStream.close();
		} catch (Exception e) {
			e.printStackTrace();
			logger.error("*****微信工具類:解析xml異常*****");
		}
		return map;
	}

httpclientpost請求

/**
	 * 傳送post請求
	 * 
	 * @param url
	 *            請求地址
	 * @param outputEntity
	 *            傳送內容 xml字串
	 * @param isLoadCert
	 * 			是否載入證書
	 * @throws IOException 
	 * @throws ClientProtocolException 
	 */
	public static CloseableHttpResponse Post(String url,String outputEntity,boolean isLoadCert) throws Exception {
		HttpPost httpPost=new HttpPost(url);
		// 得指明使用UTF-8編碼,否則到API伺服器XML的中文不能被成功識別
		httpPost.addHeader("Content-Type", "text/xml");
		httpPost.setEntity(new StringEntity(outputEntity,"UTF-8"));
		if(isLoadCert) {
			//載入含有證書的http請求
			return HttpClients.custom().setSSLSocketFactory(CommonsUtils.initCert()).build().execute(httpPost);
		}else {
			return HttpClients.custom().build().execute(httpPost);
		}
	}

需要注意的是這個工具類在之後退款也需要使用,當前付款無需載入證書,而退款需要載入證書,載入證書的工具類CommonsUtils.initCert()在後面會放上。這些方法均來自微信官方sdk模板,放心使用。

 

到此為止,我們得到了返回結果的map集合,終於拿到了所需要的prepayid(當然,首先需要判斷返回資料中的“return_code”以及“result_code”

 

3.將資料傳送給前臺,呼叫微信內建JS喚起支付控制元件

參考官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=7_7&index=6

下面對引數做具體的講解:

appId==公眾號id==同之前下單所使用的(特別注意這裡的I是大寫前面下單的是小寫)

timeStamp==時間戳==標準北京時間,時區為東八區,自1970年1月1日0點0分0秒以來的秒數。注意:需要轉換成秒(10位數字)

public static String getTimeStamp() {
		return String.valueOf((System.currentTimeMillis() / 1000));
	}

nonceStr==隨機字串==同之前下單的nonceStr,可以一樣,也可以重新生成一個

public static String generateUUID() {
		return UUID.randomUUID().toString().replace("-", "").substring(0, 32);
	}

package==統一下單介面返回的prepay_id引數值,提交格式如:prepay_id=***

上一步千辛萬苦獲得的prepayid就是用在這裡的~

signType==簽名方式==同之前下單所用的簽名方式“MD5”

paySign==簽名==簽名方式同下單,具體程式碼可以參考上面的getSign(SortedMap<String,Object> map)方法

 

現在將這6個引數傳遞給前臺H5支付頁面並調起支付,前臺JS程式碼如下:

function onBridgeReady() {
			WeixinJSBridge.invoke(
							'getBrandWCPayRequest',
							{
								"appId" : appId,
								"timeStamp" : timeStamp,
								"nonceStr" : nonceStr,
								"package" : prepayId,
								"signType" : "MD5",
								"paySign" : paySign
							},
							function(res) {
								if (res.err_msg == "get_brand_wcpay_request:ok") {
									location.href = "xxxx";
								} else {//這裡支付失敗和支付取消統一處理
									location.href = "xxxxxx";
								}
							});
		}

		$(document).ready(
				function() {
					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();
					}
				});

到此為止如果操作正常應該是會出現下面的介面

 

在正確輸入密碼後會出現下面介面

 

到此,該步驟結束

4.支付完成後,微信回撥URL的處理

在上步的JS程式碼中

function(res) {
	if (res.err_msg == "get_brand_wcpay_request:ok") {
		location.href = "xxxx";
	}else{//這裡支付失敗和支付取消統一處理
		location.href = "xxxx";
		}
	}

對於get_brand_wcpay_request:ok以及else的邏輯處理,如跳轉回商戶自己定義的一個成功頁面。

 

 

5.微信後臺非同步通知商戶支付結果,商戶收到訊息後需要告知微信處理結果

官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_7

需要注意的是,微信前臺的回撥和後臺的通知先後順序是不保證的,JSAPI返回值作為觸發商戶網頁跳轉的標誌,但商戶後臺應該只在收到微信後臺的支付成功回撥通知後,才做真正的支付成功的處理。

第一步:驗證簽名

微信會回撥我們在下單時配置的notify_url,如果之前是隨便配置的,現在就要改回來啦。微信是以流的形式將資料返回,我們接收流解析後,需要將其中的引數重新簽名並驗證,保證這個資訊是微信官方返回給我們的,防止假通知。

第二步:商戶自身業務邏輯

在驗證簽名正確並且result_code=SUCCESS情況下,商戶做自身的業務邏輯,比如和DB的互動。

第三步:商戶返回微信應答

如果微信收到商戶的應答不是成功或超時,微信認為通知失敗,微信會通過一定的策略定期重新發起通知,儘可能提高通知的成功率,但微信不保證通知最終能成功。(通知頻率為15/15/30/180/1800/1800/1800/1800/3600,單位:秒)

上面是微信官方的一些說面,我們需要保證收到微信訊息後做出相應應答,否則微信會一直通知我們,多次通知無應答後支付就失敗了。

如何通知微信呢?用response告知微信

 

String result = "<xml>" + "<return_code><![CDATA[SUCCESS]]></return_code>"+ "<return_msg><![CDATA[OK]]></return_msg>" + "</xml>";
response.getWriter().write(result);

當然,msg根據實際情況返回。下面列出整個流程的具體程式碼以及一些工具

 

整個流程code

@RequestMapping(value = "/payNotify")
	public void payNotify(HttpServletRequest request, HttpServletResponse response) throws IOException {
		logger.info("*****微信主動呼叫支付通知介面*****");
		// 微信會主動呼叫我們之前配置的notifyurl,並且以流的形式傳輸資料,首先從request中獲得inputstream
		InputStream in = request.getInputStream();
		// 用工具類將inputstream轉化成map集合
		Map<String, Object> resultMap = PayUtils.parseXml(in);
		logger.info(resultMap.toString());
		String result = "";
		// 需要進行簽名驗證,將所有的引數(除sign以外)簽名後和傳入的sign進行比對,如果正確才繼續
		if (PayUtils.checkIsSignValidFromWechat((SortedMap<String, Object>) resultMap)) {
			// 資訊處理
			String return_code = (String) resultMap.get("return_code");
			String result_code = (String) resultMap.get("result_code");
			// 由於微信後臺會同時回撥多次,所以需要做防止重複提交操作的判斷
			// 成功後商戶的業務邏輯
			if (Constant.RETURN_SUCCESS.equals(return_code) && Constant.RETURN_SUCCESS.equals(result_code)) {
				result = "<xml>" + "<return_code><![CDATA[SUCCESS]]></return_code>"
						+ "<return_msg><![CDATA[OK]]></return_msg>" + "</xml>";
				//TODO 具體的商戶邏輯
			} else {
				// FAIL的邏輯
				String err_code_des = (String) resultMap.get("err_code_des");
				logger.error("*****支付失敗*****");
				result = "<xml>" + "<return_code><![CDATA[FAIL]]></return_code>" + "<return_msg><![CDATA["
						+ err_code_des + "]]></return_msg>" + "</xml>";
			}
		} else {
			// 簽名失敗的邏輯
			logger.error("*****簽名驗證錯誤*****");
			result = "<xml>" + "<return_code><![CDATA[FAIL]]></return_code>"
					+ "<return_msg><![CDATA[簽名驗證錯誤]]></return_msg>" + "</xml>";
		}
		// 通知微信.非同步確認成功 不然微信會一直通知後臺.八次之後就認為交易失敗了.
		response.setCharacterEncoding("UTF-8");
		response.setContentType("text/xml");
		response.getWriter().write(result);
		response.getWriter().flush();
		response.getWriter().close();
	}

其中parseXml()方法上文有,就不再列出

檢驗資料中的簽名是否合法

public static boolean checkIsSignValidFromWechat(SortedMap<String, Object> map) {
		String signFromWechat =(String)map.get("sign");
		if(isEmpty(signFromWechat)) {
			logger.info("*****微信返回的資料中籤名不存在*****");
			return false;
		}
		//清除掉返回資料中的sign資料,因為sign本身是不參與簽名的
		map.remove("sign");
		String signFromCreateSign=getSign(map);
		if(!signFromWechat.equals(signFromCreateSign)) {
			//簽名驗證不通過
			logger.info("*****簽名驗證不通過*****");
			return false;
		}
		//簽名驗證通過
		logger.info("*****簽名驗證通過*****");
		return true;
	}

到此為止,整個支付流程就完成啦~下面會繼續講退款

6.退款

官方文件:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_4

和下單一樣,需要呼叫官方API介面,下面先講解欄位:

appid==公眾賬號ID==同下單

mch_id==商戶號==同下單

nonce_str==32位隨機字串==生成方法同下單

sign==簽名==簽名方法同同下單

sign_type==簽名型別==同下單

out_trade_no==商戶訂單號==該退款單的單號

out_refund_no==商戶退款單號==需要退款的單號

total_fee==訂單金額==該退款訂單的總價格

refund_fee==退款金額==需要退款的金額

所有引數賦值並簽名後轉化為xml封裝好請求

https://api.mch.weixin.qq.com/secapi/pay/refund

還是使用之前的HttpUtil.Post(Constant.REFUND_URL,reqXml, true)方法,只不過退款需要載入證書,具體HttpUtil.Post()方法參考上文,下面列出裡面載入證書的code

public static SSLConnectionSocketFactory initCert() throws Exception {
		FileInputStream instream = null;
		KeyStore keyStore = KeyStore.getInstance("PKCS12");
		instream = new FileInputStream(new File(Constant.CERT_PATH));
		keyStore.load(instream, Constant.MCH_ID.toCharArray());

		if (null != instream) {
			instream.close();
		}

		SSLContext sslContext = SSLContexts.custom().loadKeyMaterial(keyStore, Constant.MCH_ID.toCharArray()).build();
		SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext, new String[] { "TLSv1" }, null,
				SSLConnectionSocketFactory.BROWSER_COMPATIBLE_HOSTNAME_VERIFIER);

		return sslsf;
	}

其他程式碼同下單

之後通過返回的“return_code”以及“result_code”判斷是否退款成功

注意:退款金額必須大於0,否則返回錯誤

 

 

整個微信公眾號支付以及退款到此為止就結束了,如果開發時檢查仔細,測試時是可以一遍通過的。如果出現問題,根據伺服器後臺log檢視微信的一些返回值,和官方文件比對。大部分問題都是可以通過搜尋引擎解決的~得意