微信公众号对接记录java版本

微信公众号对接记录java版本

月光魔力鸭

2019-11-08 14:35 阅读 488 喜欢 0

实际上,之前有对接过的... 但是忘记了,而且也没做详细记录,同时语言又换了.. 成java了,所有这里记录下,包括从微信的授权、token、以及到js-sdk 开发为止。

开发过程中使用的测试号进行开发,需要先配置一些地址信息等。 这里开发需要使用外网,我用的是frp,可以查看这个https://chrunlee.cn/article/frp-centos-web-ssh.html

相关地址

流程介绍

接口配置信息

接口配置信息

URL 为微信调用的地址,在这里,微信服务器会发送信息到这个地址中,我们要做的就是正确的返回微信的token校验。 微信的token校验为GET访问,示例代码如下: WeChatController.java

/***
     * 验证wechat token 地址,根据接受的数据进行字典加密后如果正确返回echostr
     * @url /wechat/validateToken
     * @param signature
     * @param timestamp
     * @param nonce
     * @param echostr
     * @return
     */
    @GetMapping("/api")
    @ResponseBody
    public String validateToken(String signature,String timestamp,String nonce,String echostr) {
        try {
            if(SignUtil.checkSignature(signature, timestamp, nonce)) {
                return echostr;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "token validate failed!";
    }

验证token的工具类,SignUtil.java.

/** 
     * 验证签名 
     *  
     * @param signature 
     * @param timestamp 
     * @param nonce 
     * @return 
     */  
    public static boolean checkSignature(String signature, String timestamp, String nonce) {  
        String[] arr = new String[] { ConstantUtil.WECHAT_TOKEN/**该值为上方我们自己填写的token内容**/, timestamp, nonce };  
        // 将token、timestamp、nonce三个参数进行字典序排序  
        Arrays.sort(arr);  
        StringBuilder content = new StringBuilder();  
        for (int i = 0; i < arr.length; i++) {  
            content.append(arr[i]);  
        }  
        MessageDigest md = null;  
        String tmpStr = null;  
  
        try {  
            md = MessageDigest.getInstance("SHA-1");  
            // 将三个参数字符串拼接成一个字符串进行sha1加密  
            byte[] digest = md.digest(content.toString().getBytes());  
            tmpStr = byteToStr(digest);  
        } catch (NoSuchAlgorithmException e) {  
            e.printStackTrace();  
        }  
  
        content = null;  
        // 将sha1加密后的字符串可与signature对比,标识该请求来源于微信  
        return tmpStr != null ? tmpStr.equals(signature.toUpperCase()) : false;  
    } 
    /** 
     * 将字节数组转换为十六进制字符串 
     *  
     * @param byteArray 
     * @return 
     */  
    private static String byteToStr(byte[] byteArray) {  
        String strDigest = "";  
        for (int i = 0; i < byteArray.length; i++) {  
            strDigest += byteToHexStr(byteArray[i]);  
        }  
        return strDigest;  
    }  
  
    /** 
     * 将字节转换为十六进制字符串 
     *  
     * @param mByte 
     * @return 
     */  
    private static String byteToHexStr(byte mByte) {  
        char[] Digit = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };  
        char[] tempArr = new char[2];  
        tempArr[0] = Digit[(mByte >>> 4) & 0X0F];  
        tempArr[1] = Digit[mByte & 0X0F];  
  
        String s = new String(tempArr);  
        return s;  
    }  

此时,启动我们的项目,同时启动frp,在浏览器中访问对应的域名地址后校验token就可以啦。

js安全域名

这个域名是我们用来开发web的时候微信校验使用的,暂时先填写一个就可以,需要注意的地方是:该域名只写域名不带协议,也就是http https 等都不用写入,例如:demo.chrunlee.cn 即可。

token获取

接下来,我们就要开始获取token,通过token来调用微信的接口获取数据。关于如何保存token这个方法比较多,存文件、session、数据库等都可以,只要尽可能的保证token有效即可。

access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。

我们可以做定时刷新,也可以在使用的时候校验超期时间来刷新,总之token的获取次数每天是有限的,如果超过后,当天就无法获取了哦,测试的时候一定要注意了。

token的获取很简单,按照官方文档调去API接口即可。 WeixinUtil.java

// 获取access_token的接口地址(GET) 限200(次/天)
    public final static String access_token_url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET";

    /**
     * 获取access_token
     * 
     * @param appid 凭证
     * @param appsecret 密钥
     * @return
     */
    public static AccessToken getAccessToken(String appid, String appsecret) {
        AccessToken accessToken = null;

        String requestUrl = access_token_url.replace("APPID", appid).replace("APPSECRET", appsecret);
        JSONObject jsonObject = httpRequest(requestUrl, "GET", null);
        // 如果请求成功
        if (null != jsonObject) {
            try {
                accessToken = new AccessToken();
                accessToken.setToken(jsonObject.getString("access_token"));
                accessToken.setExpiresIn(jsonObject.getInt("expires_in"));
            } catch (JSONException e) {
                accessToken = null;
                // 获取token失败
                log.error("获取token失败 errcode:{} errmsg:{}", jsonObject.getInt("errcode"), jsonObject.getString("errmsg"));
            }
        }
        return accessToken;
    }
    /**
     * 发起https请求并获取结果
     * 
     * @param requestUrl 请求地址
     * @param requestMethod 请求方式(GET、POST)
     * @param outputStr 提交的数据
     * @return JSONObject(通过JSONObject.get(key)的方式获取json对象的属性值)
     */
    public static JSONObject httpRequest(String requestUrl, String requestMethod, String outputStr) {
        JSONObject jsonObject = null;
        StringBuffer buffer = new StringBuffer();
        try {
            // 创建SSLContext对象,并使用我们指定的信任管理器初始化
            TrustManager[] tm = { new MyX509TrustManager() };
            SSLContext sslContext = SSLContext.getInstance("SSL", "SunJSSE");
            sslContext.init(null, tm, new java.security.SecureRandom());
            // 从上述SSLContext对象中得到SSLSocketFactory对象
            SSLSocketFactory ssf = sslContext.getSocketFactory();

            URL url = new URL(requestUrl);
            HttpsURLConnection httpUrlConn = (HttpsURLConnection) url.openConnection();
            httpUrlConn.setSSLSocketFactory(ssf);

            httpUrlConn.setDoOutput(true);
            httpUrlConn.setDoInput(true);
            httpUrlConn.setUseCaches(false);
            
            // 设置请求方式(GET/POST)
            httpUrlConn.setRequestMethod(requestMethod);

            if ("GET".equalsIgnoreCase(requestMethod))
                httpUrlConn.connect();

            // 当有数据需要提交时
            if (null != outputStr) {
                OutputStream outputStream = httpUrlConn.getOutputStream();
                // 注意编码格式,防止中文乱码
                outputStream.write(outputStr.getBytes("UTF-8"));
                outputStream.close();
            }
            

            // 将返回的输入流转换成字符串
            InputStream inputStream = httpUrlConn.getInputStream();
            InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "utf-8");
            BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

            String str = null;
            while ((str = bufferedReader.readLine()) != null) {
                buffer.append(str);
            }
            bufferedReader.close();
            inputStreamReader.close();
            // 释放资源
            inputStream.close();
            inputStream = null;
            httpUrlConn.disconnect();
            jsonObject = JSONObject.fromObject(buffer.toString());
        } catch (ConnectException ce) {
            log.error("Weixin server connection timed out.");
        } catch (Exception e) {
            log.error("https request error:{}", e);
        }
        return jsonObject;
    }

接受消息

当用户在公众号发送消息的时候,微信会将消息内容转发到我们的服务器地址上,地址则是第一步我们配置的接口地址。 微信发送的数据是以xml的格式来的,所以我们要解析xml数据,具体格式参考:微信官方文档#消息管理

这里只是做了普通文本消息的整理,其他的暂未用到,可以参考下。 MessageUtil.java

package com.boyuyun.wechat.util;

import java.io.InputStream;
import java.io.Writer;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.net.nntp.Article;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import com.boyuyun.wechat.resp.ImageMessage;
import com.boyuyun.wechat.resp.TextMessage;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.core.util.QuickWriter;
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
import com.thoughtworks.xstream.io.xml.PrettyPrintWriter;
import com.thoughtworks.xstream.io.xml.XppDriver;


/**
 * 消息处理工具类
 * 
 */
public class MessageUtil {

    // 请求消息类型:文本
        public static final String REQ_MESSAGE_TYPE_TEXT = "text";
        // 请求消息类型:图片
        public static final String REQ_MESSAGE_TYPE_IMAGE = "image";
        // 请求消息类型:语音
        public static final String REQ_MESSAGE_TYPE_VOICE = "voice";
        // 请求消息类型:视频
        public static final String REQ_MESSAGE_TYPE_VIDEO = "video";
        // 请求消息类型:地理位置
        public static final String REQ_MESSAGE_TYPE_LOCATION = "location";
        // 请求消息类型:链接
        public static final String REQ_MESSAGE_TYPE_LINK = "link";

        // 请求消息类型:事件推送
        public static final String REQ_MESSAGE_TYPE_EVENT = "event";

        // 事件类型:subscribe(订阅)
        public static final String EVENT_TYPE_SUBSCRIBE = "subscribe";
        // 事件类型:unsubscribe(取消订阅)
        public static final String EVENT_TYPE_UNSUBSCRIBE = "unsubscribe";
        // 事件类型:scan(用户已关注时的扫描带参数二维码)
        public static final String EVENT_TYPE_SCAN = "scan";
        // 事件类型:LOCATION(上报地理位置)
        public static final String EVENT_TYPE_LOCATION = "LOCATION";
        // 事件类型:CLICK(自定义菜单)
        public static final String EVENT_TYPE_CLICK = "CLICK";

        // 响应消息类型:文本
        public static final String RESP_MESSAGE_TYPE_TEXT = "text";
        // 响应消息类型:图片
        public static final String RESP_MESSAGE_TYPE_IMAGE = "image";
        // 响应消息类型:语音
        public static final String RESP_MESSAGE_TYPE_VOICE = "voice";
        // 响应消息类型:视频
        public static final String RESP_MESSAGE_TYPE_VIDEO = "video";
        // 响应消息类型:音乐
        public static final String RESP_MESSAGE_TYPE_MUSIC = "music";
        // 响应消息类型:图文
        public static final String RESP_MESSAGE_TYPE_NEWS = "news";
        
        /*   新加变量    */
        public static final String RESP_MESSAGE_TYPE_VIEW = "view";

        /**
         * 扩展xstream使其支持CDATA
         */
        /*private static XStream xstream = new XStream(new XppDriver() {
            public HierarchicalStreamWriter createWriter(Writer out) {
                return new PrettyPrintWriter(out) {
                    // 对所有xml节点的转换都增加CDATA标记
                    boolean cdata = true;

                    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);
                        }
                    }
                };
            }
        });*/
        /**
         * 解析微信发来的请求(XML)
         * 
         * @param request
         * @return Map<String, String>
         * @throws Exception
         */
        @SuppressWarnings("unchecked")
        public static Map<String, String> parseXml(HttpServletRequest request) throws Exception {
            // 将解析结果存储在HashMap中
            Map<String, String> map = new HashMap<String, String>();

            // 从request中取得输入流
            InputStream inputStream = request.getInputStream();
            // 读取输入流
            SAXReader reader = new SAXReader();
            Document document = reader.read(inputStream);
            // 得到xml根元素
            Element root = document.getRootElement();
            // 得到根元素的所有子节点
            List<Element> elementList = root.elements();

            // 遍历所有子节点
            for (Element e : elementList)
                map.put(e.getName(), e.getText());

            // 释放资源
            inputStream.close();
            inputStream = null;

            return map;
        }

        

        /**
         * 文本消息对象转换成xml
         * 
         * @param textMessage 文本消息对象
         * @return xml
         */
        public static String messageToXml(TextMessage textMessage) {
            XStream xstream = new XStream();
            xstream.alias("xml", textMessage.getClass());
            return xstream.toXML(textMessage);
        }

        /**
         * 图片消息对象转换成xml
         * 
         * @param imageMessage 图片消息对象
         * @return xml
         */
        public static String messageToXml(ImageMessage imageMessage) {
            XStream xstream = new XStream();
            xstream.alias("xml", imageMessage.getClass());
            return xstream.toXML(imageMessage);
        }
}

包括用户发送的消息、用户关注或取消关注都会触发调用该接口。 返回消息的时候,只需要对照官网文档中的xml结构返回数据即可。关于封装可以参考MessageUtil.java中的messageToXml函数。

获取用户信息

在用户关注我们的公众号的时候,就可以通过接口获取到用户的基本信息啦。当然,后续的网页请求也会让用户去授权,同时也可以拿到用户信息。 以下为根据用户的openIdtoken 来获得用户的基本信息。 WeixinUtil.java

    //根据openId 获取微信用户基本信息
    public static String SERVER_USERINFO_URL = "https://api.weixin.qq.com/cgi-bin/user/info?access_token=${tokenStr}&openid=${openId}&lang=zh_CN";
    
    /***
     * 根据用户的openId 和 token 获取用户基本信息
     * @param token
     * @param openId
     * @return
     */
    public static WeiLoginInfoPo getUserInfoByServer(String token,String openId) {
        String requestUrl = SERVER_USERINFO_URL.replace("${tokenStr}", token).replace("${openId}", openId);
        JSONObject jo = httpRequest(requestUrl, "GET", null);
        //将json转成对象返回。
        System.out.println(jo.toString());
        JSONArray ja = jo.getJSONArray("tagid_list");
        String tagIdStr = "";
        for (Iterator iterator = ja.iterator(); iterator.hasNext();) {
            String object = (String) iterator.next();
            tagIdStr += object;
        }
        WeiLoginInfoPo info = new WeiLoginInfoPo();
        info.setSubscribe(jo.getInt("subscribe"));
        info.setOpenId(jo.getString("openid"));
        info.setNickName(jo.getString("nickname"));
        info.setSex(jo.getInt("sex"));
        info.setLanguage(jo.getString("language"));
        info.setCity(jo.getString("city"));
        info.setProvince(jo.getString("province"));
        info.setCountry(jo.getString("country"));
        info.setHeadimgurl(jo.getString("headimgurl"));
        info.setSubscribe_time(jo.getLong("subscribe_time"));
        info.setRemark(jo.getString("remark"));
        info.setGroupid(jo.getInt("groupid"));
        info.setTagidList(tagIdStr);
        info.setSubscribe_scene(jo.getString("subscribe_scene"));
        info.setQr_scene(jo.getInt("qr_scene"));
        info.setQr_scene_str(jo.getString("qr_scene_str"));
        return info;
    }

具体官方文档可参考:微信官方文档#获取用户基本信息 用户基本信息获取后可以保存在数据库中,用于后续的身份校验或记录。

数据走向

接下来用户就开始进入我们的应用界面啦,大体的流程如下:

  1. 用户点击链接进入我们的系统后台
  2. 后台去跳转oauth2地址,去校验用户认证信息
  3. 微信调用回调地址到我们系统中,此时我们就可以获取web token数据,然后获取js ticket
  4. 获得ticket后进行存储,返回到页面端,提供给weixin.js来使用。
  5. 此时用户访问的页面中,我们就可以通过js sdk来进行调用微信的接口函数啦。

网页对接

关于网页授权(与服务器授权基本类似)可以参考微信官方文档#网页授权

按照这几个步骤一步一步来即可

我这边的代码会掺杂一些我的业务代码,仅供参考:

    //通过accesstoken 获取js-sdk 开发的ticket
    public static String WEB_JS_TICKET_URL = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${ACCESS_TOKEN}&type=jsapi";
    /***
     * 通过token获得js-sdk 的ticket,以accessToken的形式返回
     * @param token
     * @return
     */
    public AccessToken getTicketByToken(String token) {
        String requestUrl = WEB_JS_TICKET_URL.replace("${ACCESS_TOKEN}", token);
        JSONObject jo = httpRequest(requestUrl, "GET", null);
        //将json转成对象返回。
        System.out.println(jo.toString());
        String ticket = jo.getString("ticket");
        Integer expireIn = jo.getInt("expires_in");
        AccessToken accessToken = new AccessToken();
        accessToken.setExpiresIn(expireIn);
        accessToken.setToken(ticket);
        System.out.println("---用户进入页面获得ticket : "+ticket);
        return accessToken;
    }
    /**
     * 根据传递过来的schoolWeixin 数据产生ticket并返回
     * @return
     */
    public JsTicket getTicketBySchool(SchoolWeixin school,String url) {
        /**
         * 1.校验ticket是否过期,如果过期,通过token生成并更新
         * 2.拿到ticket,然后随机生成数据,进行前面
         * 3.返回数据内容
         */
        String ticket = school.getTicket();
        Date ticketdate = school.getTicketdate();
        Date now = new Date();
        JsTicket jsTicket = new JsTicket();
        if(null == ticketdate || ticketdate.getTime() < now.getTime()) {
            //重新拿ticket
            String token = getTokenStr(school);
            AccessToken tokenPo = getTicketByToken(token);
            //更新token
            Date now2 = new Date();
            Date ticketdate2 = new Date(now2.getTime() + tokenPo.getExpiresIn()* 1000);
            school.setTicket(tokenPo.getToken());
            school.setTicketdate(ticketdate2);
            weService.updateSchoolTicket(school);
            jsTicket.setTicket(tokenPo.getToken());
        }else {
            jsTicket.setTicket(ticket);
        }
        
        String noncestr = getRandomStr(6);//随机字符串四五位
        Long timestamp = new Date().getTime();
        String timestampstr = timestamp.toString();
        //计算signature
        String str = "jsapi_ticket="+jsTicket.getTicket()+"&noncestr="+noncestr+"&timestamp="+timestampstr+"&url="+url;
        
        MessageDigest md = null;
        String tmpStr = "";
        try {
            md = MessageDigest.getInstance("SHA-1");
            // 将三个参数字符串拼接成一个字符串进行sha1加密  
            byte[] digest = md.digest(str.getBytes());  
            tmpStr = byteToStr(digest);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        jsTicket.setAppId(school.getAppId());
        jsTicket.setSignature(tmpStr);
        jsTicket.setNoncestr(noncestr);
        jsTicket.setTimestamp(timestampstr);
        jsTicket.setUrl(url);
        
        return jsTicket;
    }

网页分享/界面自定义

这几个都是前端的东西,直接按照文档写就可以了,实在没什么说的... 微信官方文档#JS-SDK

//引入weixin.js
wx.config({
  debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
  appId: '', // 必填,公众号的唯一标识
  timestamp: , // 必填,生成签名的时间戳
  nonceStr: '', // 必填,生成签名的随机串
  signature: '',// 必填,签名
  jsApiList: [] // 必填,需要使用的JS接口列表
});

安全

总之,在开发过程中一定要注意token的保持和保护,所有的校验和生成都放在后端来做,尽量保证安全。


这文章拖拖拖一直拖了俩月才想起来写.. 比较潦草,等过一段时间,我准备做一个服务号,到时候还会详细记录一下相关的坑和过程的。

转载请注明出处: https://chrunlee.cn/article/mp-weixin-java.html


感谢支持!

赞赏支持
提交评论
评论信息 (请文明评论)
暂无评论,快来快来写想法...
推荐
最近项目使用了spring-boot框架,在部署过程中出现了很多问题,这里记录下,防止后续遗忘。
之前没接触过tigase,最近开始准备用这个来做IM ,开始预研..不过中间比较坎坷,虽然有忙别的事情,但是前前后后还是花了好几天的时间,资源太少,官网又看不懂,git还下不下来... 啥机制也不懂.. 真惆怅。
使用spring boot 来传递日期参数的时候,发现报错。顺手记录。
关于即时通讯,现在各路APP基本都有,虽然不能说是核心,但是如果没有又总会觉的少点啥。 如果对即时通讯要求不高,且用户量不大,只是想要从无到有的话,可以直接使用现有的服务即可,有不少免费的足够支持小量用户的要求。但是如果用户量稍微较大,且需要一些定制服务又不想受制于人,那么搭建自己的即时通讯服务器可能是你最终的选择。但是,从头开发肯定是不可能的。
项目为长期稳定运行中,由于新增功能或修复BUG,修改代码后无法启动。根据添加的代码删减,最后确认出的问题。
记录下关于通过tomcat来配置https ,使java web项目可以直接通过https来访问,之前的时候都是走nginx来配置https,由于特殊原因443端口不能开启(具体是啥不清楚,运维不让开),只能如此配置下。
nginx 监听的服务器的9000 端口,转的443 到tomcat 中,但是在java web jsp中获取的端口却还是443.
项目为nginx+tomcat 部署的,由于需要https环境,本来直接配置nginx https就可以了,结果在jsp 中获取request.getScheme 怎么都是http .. 可把我这个假运维愁坏了。