前提是了解微信JSSDK: https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
接口只有認證公眾號才能使用,域名必須備案且在微信后台設置。先確認已經滿足使用jssdk的要求再進行開發。
0.JSSDK使用步驟
1.綁定域名
先登錄微信公眾平台進入“公眾號設置”的“功能設置”里填寫“JS接口安全域名”。備注:登錄后可在“開發者中心”查看對應的接口權限。
2.引入JS文件
在需要調用JS接口的頁面引入JS文件,(支持https):http://res.wx.qq.com/open/js/jweixin-1.4.0.js
如需進一步提升服務穩定性,當上述資源不可訪問時,可改訪問:http://res2.wx.qq.com/open/js/jweixin-1.4.0.js (支持https)。
備注:支持使用 AMD/CMD 標准模塊加載方法加載
3.通過config接口注入權限驗證配置
所有需要使用JS-SDK的頁面必須先注入配置信息,否則將無法調用(同一個url僅需調用一次,對於變化url的SPA的web app可在每次url變化時進行調用)。
簽名signature在后台生成、nonceStr采用uuid生成唯一標識,timestamp是簽名時候的時間戳
wx.config({ debug: true, // 開啟調試模式,調用的所有api的返回值會在客戶端alert出來,若要查看傳入的參數,可以在pc端打開,參數信息會通過log打出,僅在pc端時才會打印。 appId: '', // 必填,公眾號的唯一標識 timestamp: , // 必填,生成簽名的時間戳 nonceStr: '', // 必填,生成簽名的隨機串 signature: '',// 必填,簽名 jsApiList: [] // 必填,需要使用的JS接口列表 });
4.通過ready接口處理成功驗證
wx.ready(function(){ // config信息驗證后會執行ready方法,所有接口調用都必須在config接口獲得結果之后,config是一個客戶端的異步操作,所以如果需要在頁面加載時就調用相關接口,則須把相關接口放在ready函數中調用來確保正確執行。對於用戶觸發時才調用的接口,則可以直接調用,不需要放在ready函數中。 });
5.通過error接口處理失敗驗證
wx.error(function(res){ // config信息驗證失敗會執行error函數,如簽名過期導致驗證失敗,具體錯誤信息可以打開config的debug模式查看,也可以在返回的res參數中查看,對於SPA可以在這里更新簽名。 });
1. Vue中引入
1. 在 main.js
中全局引入:
// 引入微信對接模塊 import { WechatPlugin } from 'vux' Vue.use(WechatPlugin) console.log(Vue.wechat) // 可以直接訪問 wx 對象,wx對象是微信jssdk的入口
結果:
2.組件外使用
在引入插件后調用config方法進行配置,你可以通過 Vue.wechat 在組件外部訪問wx對象。jssdk需要請求簽名配置接口,你可以直接使用 VUX 基於 Axios 封裝的 AjaxPlugin。
import { WechatPlugin, AjaxPlugin } from 'vux' Vue.use(WechatPlugin) Vue.use(AjaxPlugin) Vue.http.get('/api', ({data}) => { Vue.wechat.config(data.data) })
3.組件中使用
之后任何組件中都可以通過 this.$wechat 訪問到 wx 對象。
export default { created () { this.$wechat.onMenuShareTimeline({ title: 'hello VUX' }) } }
2.實戰對接微信jssdk進行分享朋友圈內容修改
雖然微信提供了JSSDK,但是這不意味着你可以用自定義的按鈕來直接打開微信的分享界面,這套JSSDK只是把微信分享接口的內容定義好了,實際還是需要用戶點擊右上角的菜單按鈕進行主動的分享,用戶點開分享界面之后,出現的內容就會是你定義的分享標題、圖片和鏈接。
(1)main.js引入wechat模塊:
// 引入微信對接模塊 import { WechatPlugin } from 'vux' Vue.use(WechatPlugin) console.log(Vue.wechat) // 可以直接訪問 wx 對象,wx對象是微信jssdk的入口
(2)模塊中使用:
wxShare方法,第一個參數用於封裝title、link、imgUrl等參數;第二個是封裝的成功回調,下面的例子沒有用到,以后可以用這兩個參數封裝。
<script> import axios from "@/axios"; import Vue from 'vue'; export default { name: 'Constants', // 項目的根路徑(加api的會被代理請求,用於處理ajax請求) projectBaseAddress: '/api', // 微信授權后台地址,這里手動加api是用window.location.href跳轉 weixinAuthAddress: '/api/weixin/auth/login.html', async wxShare(obj, callback) { alert(1); function getUrl() { var url = window.location.href; var locationurl = url.split('#')[0]; return locationurl; } alert(2); // wx.config的參數 var wxdata = { "url": getUrl() }; //微信分享(向后台請求數據) var data = await axios.post("/weixin/auth/getJsapiSigner.html", wxdata); alert(JSON.stringify(data)); var wxdata = data.data; // 向后端返回的簽名信息添加前端處理的東西 wxdata.debug = true; wxdata.jsApiList = [ // 所有要調用的 API 都要加到這個列表中 'onMenuShareTimeline' //分享到朋友圈 ]; alert(JSON.stringify(wxdata)); Vue.wechat.config(wxdata); alert(4); Vue.wechat.ready(function() { alert(5); // 獲取“分享到朋友圈”按鈕點擊狀態及自定義分享內容接口(即將廢棄) Vue.wechat.onMenuShareTimeline({ title: '這是分享標題', // 分享標題 link: "http://ynpxwl.cn/api/login.html", // 分享鏈接 imgUrl: "http://ynpxwl.cn/api/static/x-admin/images/bg.png", // 分享圖標 success: function() { // 用戶確認分享后執行的回調函數 alert('用戶已分享'); }, cancel: function(res) { alert('用戶已取消'); }, fail: function(res) { alert(JSON.stringify(res)); } }); alert(6); }) } }; </script>
我打印的alert信息是為了測試;注意連接的link為實際的url,該鏈接域名或路徑必須與當前頁面對應的公眾號JS安全域名一致
(3)后台代碼getJsapiSigner與簽名算法如下
接收前台傳的url,調用工具類進行簽名,最后將appId傳回去:
@RequestMapping("/getJsapiSigner") @ResponseBody public JSONResultUtil<Map<String, String>> getJsapiSigner( @RequestBody(required = false) Map<String, Object> condition) { String url = MapUtils.getString(condition, "url"); Map<String, String> signers = WeixinJSAPISignUtils.sign(WeixinInterfaceUtils.getJsapiTicket(), url); signers.put("appId", WeixinConstants.APPID); logger.info("signers: {}", signers); return new JSONResultUtil<Map<String, String>>(true, "ok", signers); }
簽名算法:
package cn.qs.utils.weixin; import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Formatter; import java.util.HashMap; import java.util.Map; import java.util.UUID; public class WeixinJSAPISignUtils { public static void main(String[] args) { // 注意 URL 一定要動態獲取,不能 hardcode String url = "http://8fbb6757.ngrok.io/weixinauth/index.html"; Map<String, String> ret = sign(WeixinInterfaceUtils.getJsapiTicket(), url); for (Map.Entry entry : ret.entrySet()) { System.out.println(entry.getKey() + ", " + entry.getValue()); } } /** * 簽名 * * @param jsapiTicket * jsapiTicket * @param url * 調用接口的當前URL(不包含#以及后面部分) * @return */ public static Map<String, String> sign(String jsapiTicket, String url) { Map<String, String> ret = new HashMap<String, String>(); String nonce_str = create_nonce_str(); String timestamp = create_timestamp(); String signatureString; String signature = ""; // 注意這里參數名必須全部小寫,且必須有序(必須這樣簽名)==簽名用的noncestr和timestamp必須與wx.config中的nonceStr和timestamp相同。簽名用的url必須是調用JS接口頁面的完整URL。 signatureString = "jsapi_ticket=" + jsapiTicket + "&noncestr=" + nonce_str + "×tamp=" + timestamp + "&url=" + url; try { MessageDigest crypt = MessageDigest.getInstance("SHA-1"); crypt.reset(); crypt.update(signatureString.getBytes("UTF-8")); signature = byteToHex(crypt.digest()); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } ret.put("url", url); ret.put("jsapi_ticket", jsapiTicket); ret.put("nonceStr", nonce_str); ret.put("timestamp", timestamp); ret.put("signature", signature); return ret; } private static String byteToHex(final byte[] hash) { Formatter formatter = new Formatter(); for (byte b : hash) { formatter.format("%02x", b); } String result = formatter.toString(); formatter.close(); return result; } private static String create_nonce_str() { return UUID.randomUUID().toString(); } private static String create_timestamp() { return Long.toString(System.currentTimeMillis() / 1000); } }
獲取JsapiTicket 和 accessToken 的工具類
package cn.qs.utils.weixin; import java.util.Date; import java.util.HashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.alibaba.fastjson.JSONObject; import cn.qs.utils.HttpUtils; public class WeixinInterfaceUtils { private static final Logger LOGGER = LoggerFactory.getLogger(WeixinInterfaceUtils.class); /** * 獲取ACCESS_TOKEN */ public static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; /** * 獲取JSAPI_TICKET */ public static final String JSAPI_TICKET_URL = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapi"; // 用於管理token /** * 獲取到的accessToken */ private static String accessToken; /** * 最后一次獲取Access_Token的時間 */ private static Date lastGetAccessTokenTime; public static String getAccessToken() { if (StringUtils.isBlank(accessToken) || isExpiredAccessToken()) { accessToken = null; lastGetAccessTokenTime = null; Map<String, String> param = new HashMap<>(); param.put("grant_type", "client_credential"); param.put("appid", WeixinConstants.APPID); param.put("secret", WeixinConstants.APP_SECRET); String responseStr = HttpUtils.doGetWithParams(ACCESS_TOKEN_URL, param); if (StringUtils.isNotBlank(responseStr)) { JSONObject parseObject = JSONObject.parseObject(responseStr); if (parseObject != null && parseObject.containsKey("access_token")) { accessToken = parseObject.getString("access_token"); lastGetAccessTokenTime = new Date(); LOGGER.debug("調用接口獲取accessToken,獲取到的信息為: {}", parseObject.toString()); } } } else { LOGGER.debug("使用未過時的accessToken: {}", accessToken); } return accessToken; } private static boolean isExpiredAccessToken() { if (lastGetAccessTokenTime == null) { return true; } // 1.5小時以后的就算失效 long existTime = 5400000L; long now = System.currentTimeMillis(); if (now - lastGetAccessTokenTime.getTime() > existTime) { return true; } return false; } /** * 獲取到的jsapiTicket */ private static String jsapiTicket; /** * 最后一次獲取JsapiTicket的時間 */ private static Date lastGetJsapiTicketTime; public static String getJsapiTicket() { if (StringUtils.isBlank(jsapiTicket) || isExpiredJsapiTicket()) { jsapiTicket = null; lastGetJsapiTicketTime = null; String tmpUrl = JSAPI_TICKET_URL.replaceAll("ACCESS_TOKEN", getAccessToken()); String responseStr = HttpUtils.doGet(tmpUrl); if (StringUtils.isNotBlank(responseStr)) { JSONObject parseObject = JSONObject.parseObject(responseStr); if (parseObject != null && parseObject.containsKey("ticket")) { jsapiTicket = parseObject.getString("ticket"); lastGetJsapiTicketTime = new Date(); LOGGER.debug("調用接口獲取jsapiTicket,獲取到的信息為: {}", parseObject.toString()); } } } else { LOGGER.debug("使用未過時的jsapiTicket: {}", jsapiTicket); } return jsapiTicket; } private static boolean isExpiredJsapiTicket() { if (lastGetJsapiTicketTime == null) { return true; } // 1.5小時以后的就算失效 long existTime = 5400000L; long now = System.currentTimeMillis(); if (now - lastGetJsapiTicketTime.getTime() > existTime) { return true; } return false; } }
這里只是簡單的進行修改分享朋友圈的信息,實際中可以修改方法進一步封裝。
3. 對接微信支付
微信支付相關文檔:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1
我們需要基本的參數有 appId、商號mch_id、商號的api_key。appId和商戶號從公眾號可以直接查看,apiKey需要從公眾號-》微信支付-》商號 登錄之后進行設置。
我們查看微信JSSDK支付接口如下:
wx.chooseWXPay({ timestamp: 0, // 支付簽名時間戳,注意微信jssdk中的所有使用timestamp字段均為小寫。但最新版的支付后台生成簽名使用的timeStamp字段名需大寫其中的S字符 nonceStr: '', // 支付簽名隨機串,不長於 32 位 package: '', // 統一支付接口返回的prepay_id參數值,提交格式如:prepay_id=\*\*\*) signType: '', // 簽名方式,默認為'SHA1',使用新版支付需傳入'MD5' paySign: '', // 支付簽名 success: function (res) { // 支付成功后的回調函數 } });
備注:prepay_id 通過微信支付統一下單接口拿到,paySign 采用統一的微信支付 Sign 簽名生成方法,注意這里 appId 也要參與簽名,appId 與 config 中傳入的 appId 一致,即最后參與簽名的參數有appId, timeStamp, nonceStr, package, signType。
可以看到,需要從后台統一生成訂單,也就是說所有的訂單處理都需要先從后端通過http接口進行訂單處理,最后通過返回的標識從前台進行處理。
測試的時候我們微信提供了沙箱測試。仿真系統與生產環境完全獨立,包括存儲層。商戶在仿真系統所做的所有交易(如下單、支付、查詢)均為無資金流的假數據,即:用戶無需真實扣款,商戶也不會有資金入賬。在所有請求的URL前面加上/sandboxnew 就是沙箱測試。
我們下載文檔的demo之后進行簡單的封裝以及測是,其實其SDK已經封裝好了,包括沙箱測試環境等環境。我們獲取到SDK之后可以利用SDK現有的工具類。
1.利用沙箱測試環境進行測試
查看文檔:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=23_1&index=1
沙箱測試的key與真實環境的key有所區別,所以需要先獲取沙箱測試idkey。
1.獲取沙箱測試環境key的正確步驟
public static void main(String[] args) throws Exception { // 構造配置信息 WXPayConfig wxPayConfig = new MyWxPayConfig(); // 參與sign的字段包括mch_id、nonce_str、真實環境的key Map<String, String> param = new LinkedHashMap<>(); param.put("mch_id", wxPayConfig.getMchID()); param.put("nonce_str", WXPayUtil.generateNonceStr()); String generateSignature = WXPayUtil.generateSignature(param, wxPayConfig.getKey(), SignType.MD5); // wxPayConfig.getKey()是真實環境的key值 param.put("sign", generateSignature); // 轉為XML String mapToXml = WXPayUtil.mapToXml(param); String url = "https://api.mch.weixin.qq.com/sandboxnew/pay/getsignkey"; // 發送請求獲取XML數據 String doPost = HttpUtils.doPost(url, mapToXml); Map<String, String> xmlToMap = WXPayUtil.xmlToMap(doPost); System.out.println(xmlToMap); }
結果:
{return_msg=ok, sandbox_signkey=XXXXXXXXX, return_code=SUCCESS}
HttpUtils是自己封裝的工具類,如下:
package cn.qs.utils; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import org.apache.commons.io.IOUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; import org.apache.http.ParseException; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.entity.mime.HttpMultipartMode; import org.apache.http.entity.mime.MultipartEntityBuilder; import org.apache.http.entity.mime.content.FileBody; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; import org.apache.http.protocol.HTTP; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * http工具類的使用 * * @author Administrator * */ public class HttpUtils { private static Logger logger = LoggerFactory.getLogger(HttpUtils.class); /** * get請求 * * @return */ public static String doGet(String url) { CloseableHttpClient client = null; CloseableHttpResponse response = null; try { // 定義HttpClient client = HttpClientBuilder.create().build(); // 發送get請求 HttpGet request = new HttpGet(url); // 執行請求 response = client.execute(request); return getResponseResult(response); } catch (Exception e) { logger.error("execute error,url: {}", url, e); } finally { IOUtils.closeQuietly(response); IOUtils.closeQuietly(client); } return ""; } /** * get請求攜帶參數 * * @return */ public static String doGetWithParams(String url, Map<String, String> params) { CloseableHttpClient client = null; CloseableHttpResponse response = null; try { // 定義HttpClient client = HttpClientBuilder.create().build(); // 1.轉化參數 if (params != null && params.size() > 0) { List<NameValuePair> nvps = new ArrayList<NameValuePair>(); for (Iterator<String> iter = params.keySet().iterator(); iter.hasNext();) { String name = iter.next(); String value = params.get(name); nvps.add(new BasicNameValuePair(name, value)); } String paramsStr = EntityUtils.toString(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); url += "?" + paramsStr; } HttpGet request = new HttpGet(url); response = client.execute(request); return getResponseResult(response); } catch (IOException e) { logger.error("execute error,url: {}", url, e); } finally { IOUtils.closeQuietly(response); IOUtils.closeQuietly(client); } return ""; } public static String doPost(String url, Map<String, String> params) { CloseableHttpClient client = null; CloseableHttpResponse response = null; try { // 定義HttpClient client = HttpClientBuilder.create().build(); HttpPost request = new HttpPost(url); // 1.轉化參數 if (params != null && params.size() > 0) { List<NameValuePair> nvps = new ArrayList<NameValuePair>(); for (Iterator<String> iter = params.keySet().iterator(); iter.hasNext();) { String name = iter.next(); String value = params.get(name); nvps.add(new BasicNameValuePair(name, value)); } request.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); } response = client.execute(request); return getResponseResult(response); } catch (IOException e) { logger.error("execute error,url: {}", url, e); } finally { IOUtils.closeQuietly(response); IOUtils.closeQuietly(client); } return ""; } public static String doPost(String url, String params) { return doPost(url, params, false); } /** * post請求(用於請求json格式的參數) * * @param url * @param params * @param isJsonData * @return */ public static String doPost(String url, String params, boolean isJsonData) { CloseableHttpClient client = null; CloseableHttpResponse response = null; try { // 定義HttpClient client = HttpClientBuilder.create().build(); HttpPost request = new HttpPost(url); StringEntity entity = new StringEntity(params, HTTP.UTF_8); request.setEntity(entity); if (isJsonData) { request.setHeader("Accept", "application/json"); request.setHeader("Content-Type", "application/json"); } response = client.execute(request); return getResponseResult(response); } catch (IOException e) { logger.error("execute error,url: {}", url, e); } finally { IOUtils.closeQuietly(response); IOUtils.closeQuietly(client); } return ""; } /** * 上傳文件攜帶參數發送請求 * * @param url * URL * @param fileName * neme,相當於input的name * @param filePath * 本地路徑 * @param params * 參數 * @return */ public static String doPostWithFile(String url, String fileName, String filePath, Map<String, String> params) { CloseableHttpClient httpclient = HttpClientBuilder.create().build(); CloseableHttpResponse response = null; try { MultipartEntityBuilder multipartEntityBuilder = MultipartEntityBuilder.create(); // 上傳文件,如果不需要上傳文件注掉此行 multipartEntityBuilder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE).addPart(fileName, new FileBody(new File(filePath))); if (params != null && params.size() > 0) { Set<Entry<String, String>> entrySet = params.entrySet(); for (Entry<String, String> entry : entrySet) { multipartEntityBuilder.addTextBody(entry.getKey(), entry.getValue(), ContentType.create(HTTP.PLAIN_TEXT_TYPE, StandardCharsets.UTF_8)); } } HttpEntity httpEntity = multipartEntityBuilder.build(); HttpPost httppost = new HttpPost(url); httppost.setEntity(httpEntity); response = httpclient.execute(httppost); return getResponseResult(response); } catch (Exception e) { logger.error("execute error,url: {}", url, e); } finally { IOUtils.closeQuietly(response); IOUtils.closeQuietly(httpclient); } return ""; } private static String getResponseResult(CloseableHttpResponse response) throws ParseException, IOException { /** 請求發送成功,並得到響應 **/ if (response != null) { if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) { return EntityUtils.toString(response.getEntity(), "utf-8"); } else { logger.error("getResponseResult code error, code: {}", response.getStatusLine().getStatusCode()); } } return ""; } }
關於沙盒測試的坑:
(1)沙盒測試不能支付,也無需支付 ,下的訂單每個都是已經支付過的訂單。
(2)沙箱支付環境只能付款101分
2.真實環境
最后真實環境后台統一下單用的是 Git上的項目 best-pay-sdk
項目中只用到了微信的JSAPI方式支付,但是best-pay-sdk里面集成了微信支付、支付寶支付等方式。pom地址如下:
<dependency> <groupId>cn.springboot</groupId> <artifactId>best-pay-sdk</artifactId> <version>1.3.0</version> </dependency>
統一下單邏輯如下:
action層代碼:
/** * 統一下訂單 * * @param user * @param request * @return */ @RequestMapping("unifiedOrder") @ResponseBody public JSONResultUtil<Map<String, String>> unifiedOrder(@RequestBody Pay pay) { // 1.創建系統信息 pay.setPayDate(new Date()); pay.setUserId(MySystemUtils.getLoginUser().getId()); pay.setUsername(MySystemUtils.getLoginUser().getUsername()); String loginUsername = MySystemUtils.getLoginUsername(); User findUserByUsername = userService.findUserByUsername(loginUsername); Float coupon = ArithUtils.format(findUserByUsername.getCoupon(), 2); Float actuallyPay = pay.getPayAmount(); if (coupon != null && coupon != 0 && coupon < pay.getPayAmount()) { Float shouldPay = ArithUtils.format(pay.getPayAmount(), 2); actuallyPay = ArithUtils.sub(shouldPay, coupon); pay.setPayAmount(actuallyPay); pay.setRemark1("應收金額: " + shouldPay + ",實收金額: " + actuallyPay + ", 第一次付費減金額: " + coupon); // 去掉優惠券 findUserByUsername.setCoupon(0F); userService.update(findUserByUsername); logger.info("{}使用第一次贈送金額{}", findUserByUsername.getFullname(), coupon); } else { logger.info("沒有優惠金額"); } String orderId = UUIDUtils.getUUID2(); pay.setOrderId(orderId); pay.setOrderStatus("未支付"); payService.add(pay); // 普通用戶登錄支付訂單無需拉起支付 if (!MySystemUtils.isWXLogin()) { return new JSONResultUtil<Map<String, String>>(false, "您不是微信賬號登錄,訂單無法支付"); } // 2.創建訂單==用於JSAPI發起支付 String orderName = pay.getChildrenName() + "在幼兒園 " + pay.getKindergartenName() + "支付學費"; Map<String, String> unifiedOrder = WeixinPayUtils.unifiedOrder(orderId, orderName, actuallyPay, MySystemUtils.getLoginUser().getUsername()); unifiedOrder.put("payId", pay.getId() + ""); return new JSONResultUtil<Map<String, String>>(true, "ok", unifiedOrder); }
前面處理一些系統內部邏輯之后調用工具類生成訂單,同時將二次簽名信息返回到前台。
統一下單工具類:
package cn.qs.utils.weixin.pay; import java.util.LinkedHashMap; import java.util.Map; import com.lly835.bestpay.config.WxPayConfig; import com.lly835.bestpay.enums.BestPayTypeEnum; import com.lly835.bestpay.model.PayRequest; import com.lly835.bestpay.model.PayResponse; import com.lly835.bestpay.service.impl.BestPayServiceImpl; import cn.qs.utils.weixin.WeixinConstants; import cn.qs.utils.weixin.auth.WeixinJSAPISignUtils; public class WeixinPayUtils { private static final WxPayConfig wxPayConfig = new WxPayConfig(); static { // 公眾號支付,設置公眾號Id wxPayConfig.setAppId(WeixinConstants.APPID); wxPayConfig.setMchId(WeixinConstants.MCHID); wxPayConfig.setMchKey(WeixinConstants.API_KEY); wxPayConfig.setNotifyUrl(WeixinConstants.PAY_SUCCESS_NOTIFY_URL); } /** * 統一下單 * * @return */ public static Map<String, String> unifiedOrder(String orderId, String orderName, double amount, String openId) { // 支付類, 所有方法都在這個類里 BestPayServiceImpl bestPayService = new BestPayServiceImpl(); bestPayService.setWxPayConfig(wxPayConfig); PayRequest payRequest = new PayRequest(); payRequest.setPayTypeEnum(BestPayTypeEnum.WXPAY_MP); payRequest.setOrderId(orderId); payRequest.setOrderName(orderName); payRequest.setOrderAmount(amount); payRequest.setOpenid(openId); PayResponse pay = bestPayService.pay(payRequest); Map<String, String> result = new LinkedHashMap<>(); result.put("appId", pay.getAppId()); result.put("nonceStr", pay.getNonceStr()); result.put("timeStamp", WeixinJSAPISignUtils.getTimestamp()); result.put("package", pay.getPackAge()); result.put("signType", pay.getSignType()); result.put("paySign", pay.getPaySign()); return result; } }
wxPayConfig 是配置工具類,里面包含微信支付需要的基本參數:公眾號ID、商戶號、商戶號的API_key、以及訂單支付成功的回調地址。
回調地址不攜帶參數,如下:當支付成功微信會將訂單信息以及支付信息返回到該地址,可以根據訂單號進行處理,我的處理是將訂單狀態改為已支付。
/** * 微信成功回調地址 * * @param request * @param response * @throws IOException */ @RequestMapping("/paySuccess") public void paySuccess(HttpServletRequest request, HttpServletResponse response) throws IOException { try { InputStream inStream = request.getInputStream(); int _buffer_size = 1024; if (inStream != null) { ByteArrayOutputStream outStream = new ByteArrayOutputStream(); byte[] tempBytes = new byte[_buffer_size]; int count = -1; while ((count = inStream.read(tempBytes, 0, _buffer_size)) != -1) { outStream.write(tempBytes, 0, count); } tempBytes = null; outStream.flush(); // 將流轉換成字符串 String result = new String(outStream.toByteArray(), "UTF-8"); // 轉換為Map處理自己的業務邏輯,這里將訂單狀態改為已支付 if (StringUtils.isNotBlank(result)) { Map<String, String> xmlToMap = WxPayXmlUtil.xmlToMap(result); if ("SUCCESS".equals(MapUtils.getString(xmlToMap, "result_code", ""))) { String orderId = MapUtils.getString(xmlToMap, "out_trade_no", ""); Pay systemPay = payService.findByOrderId(orderId); if (systemPay != null && systemPay.getOrderStatus() != "已支付") { systemPay.setOrderStatus("已支付"); logger.info("修改訂單狀態為已支付, orderId: {} ", orderId); payService.update(systemPay); } } } } // 通知微信支付系統接收到信息 response.getWriter().write( "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"); } catch (Exception e) { logger.error("paySuccess error", e); // 如果失敗返回錯誤,微信會再次發送支付信息 response.getWriter().write("fail"); } }
WxPayXmlUtil工具類如下:
package cn.qs.utils.weixin.pay; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.StringWriter; import java.util.HashMap; import java.util.Map; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; 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 org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Node; import org.w3c.dom.NodeList; /** * 2018/7/3 */ public final class WxPayXmlUtil { private static final Logger LOGGER = LoggerFactory.getLogger(WxPayXmlUtil.class); public static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException { DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); documentBuilderFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); documentBuilderFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); documentBuilderFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); documentBuilderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); documentBuilderFactory.setXIncludeAware(false); documentBuilderFactory.setExpandEntityReferences(false); return documentBuilderFactory.newDocumentBuilder(); } public static Document newDocument() throws ParserConfigurationException { return newDocumentBuilder().newDocument(); } /** * 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>(); DocumentBuilder documentBuilder = WxPayXmlUtil.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) { LOGGER.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 { org.w3c.dom.Document document = WxPayXmlUtil.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; } }
補充:實際best-pat-sdk在微信下單之后已經幫我們二次簽名了,源碼如下:
@Override public PayResponse pay(PayRequest request) { WxPayUnifiedorderRequest wxRequest = new WxPayUnifiedorderRequest(); wxRequest.setOutTradeNo(request.getOrderId()); wxRequest.setTotalFee(MoneyUtil.Yuan2Fen(request.getOrderAmount())); wxRequest.setBody(request.getOrderName()); wxRequest.setOpenid(request.getOpenid()); wxRequest.setTradeType(request.getPayTypeEnum().getCode()); //小程序和app支付有獨立的appid,公眾號、h5、native都是公眾號的appid if (request.getPayTypeEnum() == BestPayTypeEnum.WXPAY_MINI){ wxRequest.setAppid(wxPayConfig.getMiniAppId()); }else if (request.getPayTypeEnum() == BestPayTypeEnum.WXPAY_APP){ wxRequest.setAppid(wxPayConfig.getAppAppId()); }else { wxRequest.setAppid(wxPayConfig.getAppId()); } wxRequest.setMchId(wxPayConfig.getMchId()); wxRequest.setNotifyUrl(wxPayConfig.getNotifyUrl()); wxRequest.setNonceStr(RandomUtil.getRandomStr()); wxRequest.setSpbillCreateIp(StringUtils.isEmpty(request.getSpbillCreateIp()) ? "8.8.8.8" : request.getSpbillCreateIp()); wxRequest.setAttach(request.getAttach()); wxRequest.setSign(WxPaySignature.sign(MapUtil.buildMap(wxRequest), wxPayConfig.getMchKey())); RequestBody body = RequestBody.create(MediaType.parse("application/xml; charset=utf-8"), XmlUtil.toString(wxRequest)); Call<WxPaySyncResponse> call = retrofit.create(WxPayApi.class).unifiedorder(body); Response<WxPaySyncResponse> retrofitResponse = null; try{ retrofitResponse = call.execute(); }catch (IOException e) { e.printStackTrace(); } assert retrofitResponse != null; if (!retrofitResponse.isSuccessful()) { throw new RuntimeException("【微信統一支付】發起支付, 網絡異常"); } WxPaySyncResponse response = retrofitResponse.body(); assert response != null; if(!response.getReturnCode().equals(WxPayConstants.SUCCESS)) { throw new RuntimeException("【微信統一支付】發起支付, returnCode != SUCCESS, returnMsg = " + response.getReturnMsg()); } if (!response.getResultCode().equals(WxPayConstants.SUCCESS)) { throw new RuntimeException("【微信統一支付】發起支付, resultCode != SUCCESS, err_code = " + response.getErrCode() + " err_code_des=" + response.getErrCodeDes()); } return buildPayResponse(response); } /** * 返回給h5的參數 * @param response * @return */ private PayResponse buildPayResponse(WxPaySyncResponse response) { String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = RandomUtil.getRandomStr(); String packAge = "prepay_id=" + response.getPrepayId(); String signType = "MD5"; //先構造要簽名的map Map<String, String> map = new HashMap<>(); map.put("appId", response.getAppid()); map.put("timeStamp", timeStamp); map.put("nonceStr", nonceStr); map.put("package", packAge); map.put("signType", signType); PayResponse payResponse = new PayResponse(); payResponse.setAppId(response.getAppid()); payResponse.setTimeStamp(timeStamp); payResponse.setNonceStr(nonceStr); payResponse.setPackAge(packAge); payResponse.setSignType(signType); payResponse.setPaySign(WxPaySignature.sign(map, wxPayConfig.getMchKey())); payResponse.setMwebUrl(response.getMwebUrl()); payResponse.setCodeUrl(response.getCodeUrl()); return payResponse; }
前台代碼:
下單的vue頁面:
async doPay() { if(!Constants.isNotBlank(this.kindergartenId, "幼兒園") || !Constants.isNotBlank(this.semester, "學期") || !Constants.isNotBlank(this.grade, "年級") || !Constants.isNotBlank(this.classNum, "班級") || !Constants.isNotBlank(this.parentPhone, "家長電話") || !Constants.isNotBlank(this.childrenName, "學生姓名")) { return; } var response = await axios.post('/pay/unifiedOrder.html', { kindergartenId: this.kindergartenId, kindergartenName: this.kindergartenName, version: this.version, server: this.server, semester: this.semester, grade: this.grade, classNum: this.classNum, parentName: this.parentName, parentPhone: this.parentPhone, childrenName: this.childrenName, payAmount: this.payAmount }); if(response.success) { // 統一下訂單 Constants.wxSPay(response.data); } }
Constant.vue
<script> import axios from "@/axios"; import Vue from 'vue'; import { AlertModule } from 'vux'; import store from '@/store'; export default { store, // 是否是開發模式 devModel: true, name: 'Constants', // 項目的根路徑(加api的會被代理請求,用於處理ajax請求) projectBaseAddress: '/api', // 微信授權后台地址,這里手動加api是用window.location.href跳轉 weixinAuthAddress: '/api/weixin/auth/login.html', /** * 獲取協議 + IP + 端口 */ getBasePath() { // 獲取當前網址,如:http://localhost:8080/MyWeb/index.html // var curWwwPath = window.document.location.href; // 獲取主機地址之后的目錄,如: MyWeb/index.html // var pathName = window.document.location.pathname; // window.location.protocol(網站協議:https、http) // window.location.host (端口號+域名;注意:80端口,只顯示域名) // 返回:https://www.domain.com:8080 var path = window.location.protocol + '//' + window.location.host return path; }, async wxConfig() { function getUrl() { var url = window.location.href; var locationurl = url.split('#')[0]; return locationurl; } // wx.config的參數 var wxdata = { "url": getUrl() }; //微信分享(向后台請求數據) var data = await axios.post("/weixin/auth/getJsapiSigner.html", wxdata); var wxdata = data.data; // 向后端返回的簽名信息添加前端處理的東西 wxdata.debug = false; // 所有要調用的 API 都要加到這個列表中 wxdata.jsApiList = ['onMenuShareTimeline', 'chooseWXPay']; Vue.wechat.config(wxdata); }, async wxShare(obj) { // 先config await this.wxConfig(); var titleValue = "測試標題"; var linkValue = "http://ynpxwl.cn/api/login.html"; var imgUrlValue = "http://ynpxwl.cn/api/static/image/0.png"; if(obj) { if(obj.title) { titleValue = obj.title; } if(obj.link) { linkValue = obj.link; } if(obj.imgUrl) { imgUrlValue = obj.imgUrl; } } Vue.wechat.ready(function() { Vue.wechat.onMenuShareTimeline({ title: titleValue, // 分享標題 link: linkValue, // 分享鏈接 imgUrl: imgUrlValue, // 分享圖標 success: function() { // 用戶確認分享后執行的回調函數(這里需要記錄后台) alert('用戶已分享'); }, cancel: function(res) { alert('用戶已取消'); }, fail: function(res) { alert(JSON.stringify(res)); } }); }) }, async wxSPay(data) { // 先config await this.wxConfig(); // 將_this指向當前vm對象 const _this = this; Vue.wechat.chooseWXPay({ appId: data.appId, timestamp: data.timeStamp, // 支付簽名時間戳,注意微信jssdk中的所有使用timestamp字段均為小寫。但最新版的支付后台生成簽名使用的timeStamp字段名需大寫其中的S字符 nonceStr: data.nonceStr, // 支付簽名隨機串,不長於 32 位 package: data.package, // 統一支付接口返回的prepay_id參數值,提交格式如:prepay_id=\*\*\*) signType: data.signType, // 簽名方式,默認為'SHA1',使用新版支付需傳入'MD5' paySign: data.paySign, // 支付簽名 success: function(res) { // 支付成功跳轉路由(路由push無效) window.location.href = "http://xxxxx.cn/#/plain/pays"; }, fail: function(res) { alert("支付失敗") } }); }, isNotBlank(value, fieldRemark) { if(!value) { AlertModule.show({ title: "提示信息", content: fieldRemark + "不能為空" }); return false; } return true; } }; </script>
注意:微信支付成功之后如果需要跳轉頁面用改變頁面地址的方法,路由push無效。
4.微信登錄
之前在學習公眾號的時候就已經學習過微信登錄了。
(1)前台
async wxLogin() { //訪問微信登陸,跳轉的地址由后台處理 window.location.replace(Constants.weixinAuthAddress); }
weixinAuthAddress值如下:
// 微信授權后台地址,這里手動加api是用window.location.href跳轉 weixinAuthAddress: '/api/weixin/auth/login.html',
實際上就是訪問后台的一個地址。
(2)后台 (用戶先從前台訪問到authorize方法,方法重定向到微信授權頁面,微信同意之后會重定向攜帶參數code和state定位到calback方法),callback可以用code獲取用戶信息進行登錄或者進行其他操作
package cn.qs.controller.weixin; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Map; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpSession; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import com.alibaba.fastjson.JSONObject; import cn.qs.bean.user.User; import cn.qs.bean.user.WechatUser; import cn.qs.service.user.UserService; import cn.qs.utils.DefaultValue; import cn.qs.utils.HttpUtils; import cn.qs.utils.JSONResultUtil; import cn.qs.utils.securty.MD5Utils; import cn.qs.utils.system.MySystemUtils; import cn.qs.utils.weixin.WeixinConstants; import cn.qs.utils.weixin.WeixinInterfaceUtils; import cn.qs.utils.weixin.auth.WeixinJSAPISignUtils; @Controller @RequestMapping("weixin/auth") public class WeixinAuthController { private static final Logger logger = org.slf4j.LoggerFactory.getLogger(WeixinAuthController.class); @Autowired private UserService userService; /** * 首頁,跳轉到index.html,index.html有一個連接會訪問下面的login方法 * * @return */ @RequestMapping("/index") public String index(ModelMap map) { // 注意 URL 一定要動態獲取,不能 hardcode String url = "http://4de70c98.ngrok.io/weixin/auth/index.html"; Map<String, String> signers = WeixinJSAPISignUtils.sign(WeixinInterfaceUtils.getJsapiTicket(), url); map.put("signers", signers); return "weixinauth/index"; } @RequestMapping("/getJsapiSigner") @ResponseBody public JSONResultUtil<Map<String, String>> getJsapiSigner( @RequestBody(required = false) Map<String, Object> condition) { String url = MapUtils.getString(condition, "url"); Map<String, String> signers = WeixinJSAPISignUtils.sign(WeixinInterfaceUtils.getJsapiTicket(), url); signers.put("appId", WeixinConstants.APPID); logger.info("signers: {}", signers); return new JSONResultUtil<Map<String, String>>(true, "ok", signers); } /** * (一)微信授權:重定向到授權頁面 * * @return * @throws UnsupportedEncodingException */ @RequestMapping("/login") public String authorize() throws UnsupportedEncodingException { // 回調地址必須在公網可以訪問 String recirectUrl = URLEncoder.encode(WeixinConstants.AUTH_REDIRECT_URL, "UTF-8"); // 授權地址 String url = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect"; url = url.replace("APPID", WeixinConstants.APPID).replace("REDIRECT_URI", recirectUrl); logger.info("url: {}", url); // 參數替換之后重定向到授權地址 return "redirect:" + url; } /** * (二)用戶同意授權; (三)微信會自動重定向到該頁面並攜帶參數code和state用於換取access_token和openid; (四) * 用access_token和openid獲取用戶信息(五)如果有必要可以進行登錄,兩種:第一種是直接拿微信號登錄; * 第二種是根據openid和nickname獲取賬號進行登錄 * * @param code * @param state * @return * @throws UnsupportedEncodingException */ @RequestMapping("/calback") public String calback(String code, String state) throws UnsupportedEncodingException { // 獲取access_token和openid try { String url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code"; url = url.replace("APPID", WeixinConstants.APPID).replace("SECRET", WeixinConstants.APP_SECRET) .replace("CODE", code); String doGet = HttpUtils.doGet(url); if (StringUtils.isNotBlank(doGet)) { JSONObject parseObject = JSONObject.parseObject(doGet); // 獲取兩個參數之后獲取用戶信息 String accessToken = parseObject.getString("access_token"); String openid = parseObject.getString("openid"); String getUserInfoURL = "https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN"; getUserInfoURL = getUserInfoURL.replace("ACCESS_TOKEN", accessToken).replace("OPENID", openid); String doGet2 = HttpUtils.doGet(getUserInfoURL); logger.debug("userInfo: {}", doGet2); // 用獲取到的用戶信息進行自己體系的登錄 if (StringUtils.isNotBlank(doGet2)) { WechatUser user = JSONObject.parseObject(doGet2, WechatUser.class); logger.debug("user: {}", user); return doLoginWithWechatUser(user); } } } catch (Exception e) { logger.error("登錄錯誤", e); } logger.info("登錄失敗了"); return "error"; } private String doLoginWithWechatUser(WechatUser wechatUser) { if (wechatUser == null || StringUtils.isBlank(wechatUser.getOpenid())) { return "獲取信息錯誤"; } String openid = wechatUser.getOpenid(); User findUserByUsername = userService.findUserByUsername(openid); if (findUserByUsername == null) { User user = new User(); user.setUsername(openid); user.setPassword(MD5Utils.md5(openid)); user.setRoles(DefaultValue.ROLE_PLAIN_USER); user.setSex("1".equals(wechatUser.getSex()) ? "男" : "女"); user.setProperty("from", "wechat"); String address = ""; if (StringUtils.isNotBlank(wechatUser.getCountry())) { address += wechatUser.getCountry(); } if (StringUtils.isNotBlank(wechatUser.getProvince())) { address += wechatUser.getProvince(); } if (StringUtils.isNotBlank(wechatUser.getCity())) { address += wechatUser.getCity(); } user.setWechataddress(address); user.setWechatnickname(wechatUser.getNickname()); user.setWechatphoto(wechatUser.getHeadimgurl()); // 設置第一次登陸的優惠金額 user.setCoupon(NumberUtils.toFloat(MySystemUtils.getProperty("coupon", "0"))); logger.debug("create user", user); userService.add(user); findUserByUsername = userService.findUserByUsername(openid); } else { logger.debug("已經存在的賬戶, {}", findUserByUsername); } HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) .getRequest(); HttpSession session = request.getSession(); session.setAttribute("user", findUserByUsername); // 登錄成功之后后台進行跳轉 String redirectUrl = ""; if (DefaultValue.ROLE_SYSYEM.equals(findUserByUsername.getUsername())) { redirectUrl = "redirect:" + WeixinConstants.ROLE_ADMIN_REDIRECTURL; } else { redirectUrl = "redirect:" + WeixinConstants.ROLE_PLAIN_REDIRECTURL; } return redirectUrl; } }
總結:
1.關於H5調用手機發起撥打電話
<a href='tel:18008426772'>18008426772</a>
親測在蘋果手機和安卓手機都有效。
2.快速清空微信瀏覽器中的緩存
(1)在微信聊天框中輸入debugx5.qq.com 並發送
(2)點擊該網址進入,在新頁面下拉菜單至最底部。
(3)選中Cookie、文件緩存、廣告過濾緩存和DNS緩存,點擊“清除”即可清除完成。