vue对接微信JSSDK实现微信登录、修改发送到朋友圈内容、微信支付
前提是了解微信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缓存,点击“清除”即可清除完成。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
2017-12-22 【IneliJ 】使用IneliJ IDEA 2016将Java Web项目导出为War包
2017-12-22 【IntelliJ】IDEA使用--字体、编码和基本设置
2017-12-22 【IntelliJ 】设置 IntelliJ IDEA 主题和字体的方法
2017-12-22 【Intellij 】Intellij IDEA 添加jar包的三种方式
2017-12-22 【intellij】intellij idea 建立与src级别的目录