微信公众号支付开发 --Java
2016-09-06 17:20
495 查看
公众号支付是用户在微信中打开商户的H5页面,商户在H5页面通过调用微信支付提供的JSAPI接口调起微信支付模块完成支付。应用场景有:
◆ 用户在微信公众账号内进入商家公众号,打开某个主页面,完成支付
◆ 用户的好友在朋友圈、聊天窗口等分享商家页面连接,用户点击链接打开商家页面,完成支付
◆ 将商户页面转换成二维码,用户扫描二维码后在微信浏览器中打开页面后完成支付
项目描述:本系统采用SpringMVC 、AngularJs框架进行开发,开发的系统依托于微信,即在微信公众账号内进入商家公众号,并打开相应的支付页面,完成支付。因此需要采用公众号支付。
项目开发流程:
一、微信网页授权,即获取code
首先前端通过url转到login方法,并携带相应的参数如PayVO类,如totalFee等。而后调用getOauthConnectUrl方法获得拼接的地址,并把拼接后的地址传给前端,在前端转向改链接。
1.拼接的地址传给前端触发,原因不清楚,后端触发不了,报“跨域”的错误
2.只能在微信中打开才能获得code,并且打开连接的微信账号必须关注相应的公众号才可以获得
3. 获得code的url如下图,可以看到有两种方式。应用授权作用域scope取snsapi_base
时,不弹出授权页面,直接跳转,只能获取用户openid;当scope取snsapi_userinfo 时弹出授权页面,可通过openid拿到昵称、性别、所在地,即使在未关注的情况下,只要用户授权,也能获取其信息。
return connectUrl.replace("APPID", appId) .replace("REDIRECT_URI", UrlUtil.urlEncode(redirectUrl)) .replace("SCOPE", oauthScope) .replace("STATE", stateUrl); }
[b]二、通过code换取网页授权access_token,继而获得openid[/b]
第一步中的redirect_url指向下面的地址,当成功时会携带code和相应的参数跳转到该方法下,在该方法里调用getOauthAccessToken方法获得相应的内容,如WxOauthAccessToken类,get和set方法隐藏掉了。
三、执行统一下单接口
首先初始化统一接口需要的各个必须参数(公众号支付统一下单接口),而后调用JsApiReqUtils类的httpsRequest方法请求微信的统一下单接口。其次将返回的值根据前端JsAPi需要的值(发起一个微信支付请求)进行封装并返回给前端,此时是采取的是url带值的方式。
类StringUtil主要用于产生随机字符串。
类JsApiReqUtils用于获得网页支付提交用户端ip和触发http请求。
MobiMessage类主要用于把json格式的数据转换为微信需要的xml格式的数据,并把返回值从xml格式解析为map格式。
DateUtils类主要用于获得当前的时间戳。
类DictionarySort主要根据微信通过的签名规则用于签名,其中key设置路径:微信商户平台-->账户中心-->API安全-->API密钥-->设置API密钥。
四、汇款后的通知页面
统一下单的参数notify_url决定了付款后的通知地址,应该把起设为改地址,如
注意:此时微信会重复发送通知给应用,因此此时接受通知业务的逻辑必须能够识别是否为第一次通知,不然会重复处理接到的通知。官方文档上说可以给微信返回处理成功的通知,但经过多次试验都没能够解决该问题。因此最后采取在业务逻辑上处理通知,即第一次通知进行处理,其余的通知一律忽视。
五、设置测试目录
必须设置公众号支付的测试授权目录和支付授权目录,该目录必须和发起支付请求的页面保持一致,比如发起支付请求的url是http://***/#/pay,只需要把该目录设为http://***/即可。
◆ 用户在微信公众账号内进入商家公众号,打开某个主页面,完成支付
◆ 用户的好友在朋友圈、聊天窗口等分享商家页面连接,用户点击链接打开商家页面,完成支付
◆ 将商户页面转换成二维码,用户扫描二维码后在微信浏览器中打开页面后完成支付
项目描述:本系统采用SpringMVC 、AngularJs框架进行开发,开发的系统依托于微信,即在微信公众账号内进入商家公众号,并打开相应的支付页面,完成支付。因此需要采用公众号支付。
项目开发流程:
一、微信网页授权,即获取code
首先前端通过url转到login方法,并携带相应的参数如PayVO类,如totalFee等。而后调用getOauthConnectUrl方法获得拼接的地址,并把拼接后的地址传给前端,在前端转向改链接。
1.拼接的地址传给前端触发,原因不清楚,后端触发不了,报“跨域”的错误
2.只能在微信中打开才能获得code,并且打开连接的微信账号必须关注相应的公众号才可以获得
3. 获得code的url如下图,可以看到有两种方式。应用授权作用域scope取snsapi_base
时,不弹出授权页面,直接跳转,只能获取用户openid;当scope取snsapi_userinfo 时弹出授权页面,可通过openid拿到昵称、性别、所在地,即使在未关注的情况下,只要用户授权,也能获取其信息。
/** * 获得用户的code,并转向支付页面(pay/pay) * @return */ @RequestMapping("/login") @ResponseBody public JsonApi login(PayVO payVO) { LogUtils.trace("--------------------/pay/login-------------------------------"); String oauthUrl; //重定向链接 oauthUrl = PayUtil.getOauthConnectUrl(WechatConts.OauthScope.BASE, payVO.getTotalFee()); LogUtils.trace("oauthUrl:" + oauthUrl); Map<String, String> map = new HashMap<String, String>() ; map.put("url", oauthUrl); return new JsonApi(map); }
public class PayVO { private String totalFee; public String getTotalFee() { return totalFee; } public void setTotalFee(String totalFee) { this.totalFee = totalFee; } }
/** * 获得授权URL * @param scope * @param state * @return */ public static String getOauthConnectUrl(WechatConts.OauthScope scope, String state) { /** * 获得访问授权所需要的信息 * 如:appId, Redirect_uri, scope */ String appId = WxConfigure.AppId; String redirectUrl = "http://" + WxConfigure.Redirect_uri; String connectUrl = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect"; String oauthScope = scope.getScope(); if (state.length() > 100) { LogUtils.info("stateUrl too Long:" + state); state = state.substring(0, 100); } String stateUrl 4000 = UrlUtil.urlEncode(state); if (stateUrl.length() >= 128) { //微信允许的最大state长度是128位 LogUtils.info("stateUrl too Long" + stateUrl); stateUrl = UrlUtil.urlEncode(state.substring(0, 80)); } <pre name="code" class="java">public class WxOauthAccessToken { int errCode; String errMsg; String accessToken; //公众号的唯一标识 int expiresIn; String refreshToken; String openId; String scope; public WxOauthAccessToken(ErrorMng.ErrorCode errorCode) { this.errCode = errorCode.getErrorID(); this.errMsg = errorCode.getErrorMsg(); } public WxOauthAccessToken(JSONObject jsonObject) { if (jsonObject.getInteger("errcode") == null) { this.accessToken = (String) jsonObject.get("access_token"); this.expiresIn = (int) jsonObject.get("expires_in"); this.refreshToken = (String) jsonObject.get("refresh_token"); this.openId = (String) jsonObject.get("openid"); this.scope = (String) jsonObject.get("scope"); } else { this.errCode = jsonObject.getInteger("errcode"); this.errMsg = jsonObject.getString("errmsg"); } }
return connectUrl.replace("APPID", appId) .replace("REDIRECT_URI", UrlUtil.urlEncode(redirectUrl)) .replace("SCOPE", oauthScope) .replace("STATE", stateUrl); }
[b]二、通过code换取网页授权access_token,继而获得openid[/b]
第一步中的redirect_url指向下面的地址,当成功时会携带code和相应的参数跳转到该方法下,在该方法里调用getOauthAccessToken方法获得相应的内容,如WxOauthAccessToken类,get和set方法隐藏掉了。
public class WxOauthAccessToken { int errCode; String errMsg; String accessToken; //公众号的唯一标识 int expiresIn; String refreshToken; String openId; String scope; public WxOauthAccessToken(ErrorMng.ErrorCode errorCode) { this.errCode = errorCode.getErrorID(); this.errMsg = errorCode.getErrorMsg(); } public WxOauthAccessToken(JSONObject jsonObject) { if (jsonObject.getInteger("errcode") == null) { this.accessToken = (String) jsonObject.get("access_token"); this.expiresIn = (int) jsonObject.get("expires_in"); this.refreshToken = (String) jsonObject.get("refresh_token"); this.openId = (String) jsonObject.get("openid"); this.scope = (String) jsonObject.get("scope"); } else { this.errCode = jsonObject.getInteger("errcode"); this.errMsg = jsonObject.getString("errmsg"); } }
/** * 付款页面 * @param request * @param response * @return * @throws Exception */ @RequestMapping("/callback") public String pay(HttpServletRequest request, HttpServletResponse response) throws Exception { LogUtils.trace("-----------------------/pay/callback/--------------------------------"); /** * 获得code和state字段 */ String code = request.getParameter("code"); String state = request.getParameter("state"); int total = (int)(Float.parseFloat(state)*100); /** * 获取openId */ WxOauthAccessToken oauthAccessToken = PayUtil.getOauthAccessToken(code); String openId = oauthAccessToken.getOpenId();
/** * 需要第一步引导获得用户的code,才能拿到该用户的accessToken * @param code * @return */ public static WxOauthAccessToken getOauthAccessToken(String code) { String oauthUrl = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code"; try { JSONObject oauthResult = JsonApi.getJson(oauthUrl.replace("APPID", AccessInfo.AppId.VALUE.getAppId()) .replace("SECRET", AccessInfo.AppSecret.VALUE.getAppSecret()) .replace("CODE", code)); return new WxOauthAccessToken(oauthResult); } catch (IOException e) { LogUtils.error("Wechat Get Oauth AccessToken Fail" + e.getStackTrace()); return new WxOauthAccessToken(ErrorMng.ErrorCode.WECHAT_API_INVOKE_FAIL); } }
三、执行统一下单接口
首先初始化统一接口需要的各个必须参数(公众号支付统一下单接口),而后调用JsApiReqUtils类的httpsRequest方法请求微信的统一下单接口。其次将返回的值根据前端JsAPi需要的值(发起一个微信支付请求)进行封装并返回给前端,此时是采取的是url带值的方式。
/** * 付款页面 * @param request * @param response * @return * @throws Exception */ @RequestMapping("/callback") public String pay(HttpServletRequest request, HttpServletResponse response) throws Exception { LogUtils.trace("-----------------------/pay/callback/--------------------------------"); /** * 获得code和state字段 */ String code = request.getParameter("code"); String state = request.getParameter("state"); int total = (int)(Float.parseFloat(state)*100); /** * 获取openId */ WxOauthAccessToken oauthAccessToken = PayUtil.getOauthAccessToken(code); String openId = oauthAccessToken.getOpenId(); /** * 执行统一下单接口,初始化各参数 */ Date now = new Date(); SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss"); String outTradeNo = "NO" + dateFormat.format( now ); String body = "测试"; String nonceStr = StringUtil.generateRandomString(16); // String spBillCreateIP = ReqUtils.getRealIp(request); String spBillCreateIP = "127.0.0.1"; //初始化传入参数 JsApiReqData jsApiReqData = new JsApiReqData(body,nonceStr,outTradeNo,total,spBillCreateIP, openId); String info = MobiMessage.JsApiReqData2xml(jsApiReqData).replaceAll("__", "_"); LogUtils.trace(info); String url = "https://api.mch.weixin.qq.com/pay/unifiedorder"; StringBuffer sb = JsApiReqUtils.httpsRequest(url, "POST", info); if (!"".equals(sb.toString())) { /** * 得到预支付ID,即prepay_id */ Map<String, String> getMap = MobiMessage.parseXml(new String(sb.toString().getBytes(), "utf-8")); LogUtils.trace(getMap); String prepay_id = getMap.get("prepay_id"); LogUtils.trace(prepay_id); // 生成支付签名,这个签名给微信支付的调用使用 Integer timeStamp = DateUtils.getCurrentTimestamp(); JsApiToJsData jsApiToJsData = new JsApiToJsData(timeStamp, nonceStr,prepay_id); String redirectUrl = "/#pay?appId=APPID&timeStamp=TIMESTAMP&nonceStr=NONCESTR&prepay_id=PREPAYID&signType=MD5&paySign=SIGN"; redirectUrl = redirectUrl.replace("APPID", jsApiToJsData.getAppid()) .replace("TIMESTAMP", jsApiToJsData.getTimeStamp() + "") .replace("NONCESTR", jsApiToJsData.getNonce_str()) .replace("PREPAYID", jsApiToJsData.getPrepay_id()) .replace("SIGN", jsApiToJsData.getSign()); LogUtils.trace(redirectUrl); return "redirect:" + redirectUrl ; } else { LogUtils.trace("统一下单失败!"); return ""; } }
/** * 请求公众号支付API需要提交的数据 * @author Created by fenghui. * @date Created on 2016/09/06/9:28 **/ public class JsApiReqData { //每个字段具体的意思请查看API文档 private String appid = ""; private String mch_id = ""; private String body = ""; private String nonce_str = ""; private String sign = ""; private String notify_url = ""; private String out_trade_no = ""; private String spbill_create_ip = ""; private Integer total_fee = 0; private String trade_type = ""; private String openid = ""; /** * @param body 要支付的商品的描述信息,用户会在支付成功页面里看到这个信息 * @param nonceStr 随机字符串,不长于32 位 * @param outTradeNo 商户系统内部的订单号,32个字符内可包含字母, 确保在商户系统唯一 * @param totalFee 订单总金额,单位为“分”,只能整数 * @param spBillCreateIP 订单生成的机器IP * @param openid 用户标识,trade_type=JSAPI,此参数必传,用户在商户appid下的唯一标识。 */ public JsApiReqData(String body,String nonceStr,String outTradeNo,Integer totalFee,String spBillCreateIP, String openid){ //微信分配的公众号ID(开通公众号之后可以获取到) setAppid(WxConfigure.AppId); //微信支付分配的商户号ID(开通公众号的微信支付功能之后可以获取到) setMch_id(WxConfigure.Mch_id); //接收微信支付异步通知后的回调地址 setNotify_url(WxConfigure.Notify_url); //交易类型,取值如下:JSAPI,NATIVE,APP, setTrade_type(WxConfigure.Trade_type); //要支付的商品的描述信息,用户会在支付成功页面里看到这个信息 setBody(body); //商户系统内部的订单号,32个字符内可包含字母, 确保在商户系统唯一 setOut_trade_no(outTradeNo); //订单总金额,单位为“分”,只能整数 setTotal_fee(totalFee); //订单生成的机器IP setSpbill_create_ip(spBillCreateIP); //随机字符串,不长于32 位 setNonce_str(nonceStr); //用户标识,trade_type=JSAPI,此参数必传,用户在商户appid下的唯一标识。 setOpenid(openid); //根据API给的签名规则进行签名 SortedMap<Object, Object> parameters = new TreeMap<Object, Object>(); parameters.put("appid", appid); parameters.put("mch_id", mch_id); parameters.put("nonce_str", nonce_str); parameters.put("body", body); parameters.put("out_trade_no", out_trade_no); parameters.put("notify_url", notify_url); parameters.put("total_fee", total_fee); parameters.put("spbill_create_ip", spbill_create_ip); parameters.put("trade_type", trade_type); parameters.put("openid", openid); String sign = DictionarySort.createSign(parameters); //根据给的签名规则进行签名 setSign(sign);//把签名数据设置到Sign这个属性中 }
public class JsApiToJsData { //每个字段具体的意思请查看API文档 private String appid = fa15 ""; private Integer timeStamp = 0; private String nonce_str = ""; private String sign = ""; private String prepay_id = ""; private String signType = ""; /** * @param nonceStr 随机字符串,不长于32 位 */ public JsApiToJsData(Integer timeStamp,String nonceStr,String prepay_id){ //微信分配的公众号ID(开通公众号之后可以获取到) setAppid(WxConfigure.AppId); setTimeStamp(timeStamp); setNonce_str(nonceStr); setPrepay_id(prepay_id); setSignType("MD5"); //根据API给的签名规则进行签名 SortedMap<Object,Object> parameters = new TreeMap<Object,Object>(); parameters.put("appId", appid); parameters.put("timeStamp", timeStamp); parameters.put("nonceStr", nonceStr); parameters.put("package", "prepay_id=" + prepay_id); parameters.put("signType",signType); //根据给的签名规则进行签名 String sign = DictionarySort.createSign(parameters); setSign(sign);//把签名数据设置到Sign这个属性中 }
类StringUtil主要用于产生随机字符串。
public class StringUtil { public static String generateRandomString() { return generateRandomString(16); } public static String generateRandomString(int length) { String seekChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; int seekLength = seekChars.length(); String str = ""; for (int i = 0; i < length; i++) { str += seekChars.charAt((int) (Math.random() * seekLength)); } return str; } public static String generateRandomNumber() { return generateRandomNumber(6); } public static String generateRandomNumber(int length) { String seekChars = "0123456789"; int seekLength = seekChars.length(); String str = ""; for (int i = 0; i < length; i++) { str += seekChars.charAt((int) (Math.random() * seekLength)); } return str; } }
类JsApiReqUtils用于获得网页支付提交用户端ip和触发http请求。
public class JsApiReqUtils { //获取真实IP public static String getRealIp(HttpServletRequest request) { if (request == null) { LogUtils.error("getRealIp request null"); return "0.0.0.0"; } String realIp = request.getHeader("X-Real-IP"); LogUtils.trace("realIp:" + realIp); if (realIp != null && realIp.length() > 0) { return realIp; } realIp = request.getHeader("X-Forwarded-For"); LogUtils.trace("realIp2:" + realIp); if (realIp != null && realIp.length() > 0) { return realIp; } return request.getRemoteAddr(); } public static StringBuffer httpsRequest(String requestUrl, String requestMethod, String output) throws Exception { HttpURLConnection conn = (HttpURLConnection) new URL(requestUrl).openConnection(); //加入数据 conn.setRequestMethod(requestMethod); conn.setDoOutput(true); BufferedOutputStream buffOutStr = new BufferedOutputStream(conn.getOutputStream()); buffOutStr.write(output.getBytes("utf-8")); buffOutStr.flush(); buffOutStr.close(); //获取输入流 BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line = null; StringBuffer sb = new StringBuffer(); while((line = reader.readLine())!= null){ sb.append(line); } return sb; } }
MobiMessage类主要用于把json格式的数据转换为微信需要的xml格式的数据,并把返回值从xml格式解析为map格式。
public class MobiMessage { public static Map<String,String> xml2map(HttpServletRequest request) throws IOException, DocumentException { Map<String,String> map = new HashMap<String, String>(); SAXReader reader = new SAXReader(); InputStream inputStream = request.getInputStream(); Document document = reader.read(inputStream); Element root = document.getRootElement(); List<Element> list = root.elements(); for(Element e:list){ map.put(e.getName(), e.getText()); } inputStream.close(); return map; } //订单转换成xml public static String JsApiReqData2xml(JsApiReqData jsApiReqData){ /*XStream xStream = new XStream(); xStream.alias("xml",productInfo.getClass()); return xStream.toXML(productInfo);*/ MobiMessage.xstream.alias("xml",jsApiReqData.getClass()); return MobiMessage.xstream.toXML(jsApiReqData); } public static String RefundReqData2xml(RefundReqData refundReqData){ /*XStream xStream = new XStream(); xStream.alias("xml",productInfo.getClass()); return xStream.toXML(productInfo);*/ MobiMessage.xstream.alias("xml",refundReqData.getClass()); return MobiMessage.xstream.toXML(refundReqData); } public static String class2xml(Object object){ return ""; } public static Map<String, String> parseXml(String xml) throws Exception { Map<String, String> map = new HashMap<String, String>(); Document document = DocumentHelper.parseText(xml); Element root = document.getRootElement(); List<Element> elementList = root.elements(); for (Element e : elementList) map.put(e.getName(), e.getText()); return map; } //扩展xstream,使其支持CDATA块 private static XStream xstream = new XStream(new XppDriver() { public HierarchicalStreamWriter createWriter(Writer out) { return new PrettyPrintWriter(out) { // 对所有xml节点的转换都增加CDATA标记 boolean cdata = true; //@SuppressWarnings("unchecked") public void startNode(String name, Class clazz) { super.startNode(name, clazz); } protected void writeText(QuickWriter writer, String text) { if (cdata) { writer.write("<![CDATA["); writer.write(text); writer.write("]]>"); } else { writer.write(text); } } }; } }); }
DateUtils类主要用于获得当前的时间戳。
public class DateUtils { public static int minTimestamp = 0; //最小的时间戳 public static int maxTimestamp = 1999999999;//最大的时间戳 public static int offset = 0; /** * 按照yyyy-MM-dd HH:mm:ss的格式,日期转字符串 * * @param date * @return yyyy-MM-dd HH:mm:ss */ public static String date2Str(Date date) { return date2Str(date, "yyyy-MM-dd HH:mm:ss"); } /** * 按照参数format的格式,日期转字符串 * * @param date * @param format * @return */ public static String date2Str(Date date, String format) { if (date != null) { SimpleDateFormat sdf = new SimpleDateFormat(format); return sdf.format(date); } else { return ""; } } public static int getCurrentTimestamp() { return (int) (System.currentTimeMillis() / 1000 + offset); } public static boolean setCurrentTimestamp(int timestamp) { int systemTimestamp = (int) (System.currentTimeMillis() / 1000); offset = timestamp - systemTimestamp; return true; } }
类DictionarySort主要根据微信通过的签名规则用于签名,其中key设置路径:微信商户平台-->账户中心-->API安全-->API密钥-->设置API密钥。
public class DictionarySort { /** * 签名算法 * @param parameters * @return */ public static String createSign(SortedMap<Object, Object> parameters) { StringBuffer sb = new StringBuffer(); Set es = parameters.entrySet();//所有参与传参的参数按照accsii排序(升序) Iterator it = es.iterator(); String key = "1082ae0cd40043df9c4531b597970646"; while (it.hasNext()) { Map.Entry entry = (Map.Entry) it.next(); String k = (String) entry.getKey(); Object v = entry.getValue(); if (null != v && !"".equals(v) && !"sign".equals(k) && !"key".equals(k)) { sb.append(k + "=" + v + "&"); } } sb.append("key=" + key); LogUtils.trace(sb.toString()); String sign = CryptoUtils.MD5(sb.toString()).toUpperCase(); return sign; } }
四、汇款后的通知页面
统一下单的参数notify_url决定了付款后的通知地址,应该把起设为改地址,如
//付款成功后的通知地址 public static final String Notify_url = "***/pay/notification";
注意:此时微信会重复发送通知给应用,因此此时接受通知业务的逻辑必须能够识别是否为第一次通知,不然会重复处理接到的通知。官方文档上说可以给微信返回处理成功的通知,但经过多次试验都没能够解决该问题。因此最后采取在业务逻辑上处理通知,即第一次通知进行处理,其余的通知一律忽视。
/** * 付款后通知跳轉頁面 * @param request * @param response * @return * @throws IOException * @throws DocumentException */ @RequestMapping("/notification") @ResponseBody public JsonApi notification(HttpServletRequest request,HttpServletResponse response) throws IOException, DocumentException { Map<String, String> map = MobiMessage.xml2map(request); LogUtils.trace(map); //根据测试成功后的返回数据来进行业务逻辑操作,比如存数据库等 return new JsonApi(); }
五、设置测试目录
必须设置公众号支付的测试授权目录和支付授权目录,该目录必须和发起支付请求的页面保持一致,比如发起支付请求的url是http://***/#/pay,只需要把该目录设为http://***/即可。
相关文章推荐
- 微信公众号开发---微信服务号支付实现(java)
- 微信公众号支付开发-JAVA版DEMO
- java 微信公众号支付接口开发总结
- 微信公众号支付开发全过程 --JAVA
- Java版微信公众号支付开发全过程
- 微信公众号支付开发全过程----JAVA
- 微信公众号支付开发全过程 --JAVA
- 【转】微信公众号支付开发全过程 --JAVA
- 微信公众号支付开发全过程(java版)
- java开发微信公众号支付
- java微信公众号支付开发之现金红包
- 微信公众号开发《五》基于Java实现微信支付(公众号支付)简单教程
- java开发微信公众号支付
- 微信公众号开发---实现微信扫一扫支付 (java)
- 微信公众号支付的开发经历 2016年java版
- 用java开发微信公众号:公众号接入和access_token管理(二)
- 用java开发微信公众号:测试公众号与本地测试环境搭建(一)
- 第六篇 :微信公众平台开发实战Java版之如何自定义微信公众号菜单
- java微信公众号开发总结(2)——文本消息处理
- java微信公众号开发总结(1)——接口认证