微信公眾號支付/退款(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
訪問如下連結:
每個引數的具體含義參照上方官方文件,其中幾個注意點:微信支付情景下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:
返回資料為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檢視微信的一些返回值,和官方文件比對。大部分問題都是可以通過搜尋引擎解決的~