Java公众号登录

背景

很久没写博客了,写点跟公众号相关的吧,相信大家一定都见过,一个网站,点击登录按钮,会出现微信扫码登录或者手机账号密码登录,而点击微信扫码登录,会出现一张二维码,扫描这个二维码,然后跳转到相应的公众号,点击关注之后才能登录成功,这样能很好的给公众号进行导流,这里我们说的就是微信扫码,跳转到公众号,关注之后再进行登录。

先叨叨两句

这里先放上官方文档地址:https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html,个人认为公众号文档还是写的比小程序好的。开始之前建议先通读下这三个tab的文档:

图1

然后我们去微信公众号设置页面:开发->基本配置下设置开发者密码,把你本地和开发环境IP白名单配置好,然后再去设置服务器配置:填写服务器回调地址,令牌,消息加密秘钥,消息加密方式为:安全模式,这一步配置微信会回调你的配置的接口,所以要先准备好回调接口,不然无法成功。准备好之后,下面进入开发。

需要的额外包

因为上一篇文章大家都说jar包不知道是哪个,这次我都详细列出来了,其实只要稍微用点心应该是知道的,重要的是思路,而不是只Ctrl+c,Ctrl+v


   com.github.liyiorg
   weixin-popular
   2.8.28


   dom4j
   dom4j
   1.6.1


   com.thoughtworks.xstream
   xstream
   1.4.11.1


   com.github.liyiorg
   weixin-popular
   2.8.28

配置文件

在你的项目的全局配置文件.yml中加入如下配置:

wechat:
 subscription:
   auth:
     appid: 你的APPID
     secret: 你的secret
     token: 你的token
     encodingAesKey: 你的消息加密秘钥

正式开始

写一个登录的controller:

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;

@Api(value = "wechatSubscriptionLogin", tags = "微信公众号登录相关接口")
@RequestMapping(value = "/wechat-subscription/login")
@RestController(value = "WeChatSubscriptionLoginController")
public class WeChatSubscriptionLoginController{
    @Value("${wechat.subscription.auth.token}")
    private String token;
    
    @Resource
    WechatService wechatService;
    
    private final Logger logger = LoggerFactory.getLogger(WeChatSubscriptionLoginController.class);
    
    @ApiOperation(value = "获取登录二维码", httpMethod = "GET")
    @GetMapping("/ticket")
    public String getTicket(@RequestParam String sceneStr) throws Exception {
        String result = wechatService.getTicket(sceneStr);
        return result;
    }

    @ApiOperation(value = "检查是否登录", httpMethod = "GET")
    @GetMapping("/check-login")
    public UserInfoDTO checkLogin(@RequestParam String sceneStr) throws Exception {
        UserInfoDTO userInfoDTO = wechatService.checkLoginReturnToken(sceneStr);
        return userInfoDTO;
    }

    @ApiOperation(value = "微信回调", httpMethod = "GET")
    @GetMapping("/callback")
    public void checkWechat(HttpServletRequest request, HttpServletResponse response) throws Exception {
        logger.info("进入回调get方法");
        // 微信加密签名
        String signature = request.getParameter("signature");
        // 时间戳
        String timestamp = request.getParameter("timestamp");
        // 随机数
        String nonce = request.getParameter("nonce");
        // 随机字符串
        String echostr = request.getParameter("echostr");

        PrintWriter out = response.getWriter();

        logger.info("参数为,signature:" + signature + ",timestamp:" +
                timestamp + ",nonce" + nonce + ",echostr" + echostr);

        // 通过检验signature对请求进行校验,若校验成功则原样返回echostr,表示接入成功,否则接入失败
        if (SignUtil.checkSignature(signature, timestamp, nonce, token)) {
            logger.info("验签成功");
            out.print(echostr);
        }
        out.close();
    }

    @ApiOperation(value = "微信回调", httpMethod = "POST")
    @PostMapping("/callback")
    public void callBack(HttpServletRequest request, HttpServletResponse response) throws Exception {
        // 微信加密签名
        String signature = request.getParameter("msg_signature");
        // 时间戳
        String timestamp = request.getParameter("timestamp");
        // 随机数
        String nonce = request.getParameter("nonce");

        wechatService.callBack(request.getInputStream(), response.getWriter(), signature, timestamp, nonce);
    }
}

这里我们可以看到最后有两个/callback接口,一个是get请求,用于文章开头所说的设置服务器配置,所以这个接口要先发布上线,才能配置成功,另一个是post请求,这个就是用于公众号接收到用户的动作之后回调我们的接口。另外两个接口暂时不用管,我们回过头再说。其中:signutil工具类的代码为:

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

/**
 * @author luoling
 * @date 2020-04-27 11:53
 */
public class SignUtil {
    public static boolean checkSignature(String signature, String timestamp,
                                         String nonce, String token) {
        // 1.将token、timestamp、nonce三个参数进行字典序排序
        String[] arr = new String[]{token, timestamp, nonce};
        Arrays.sort(arr);

        // 2. 将三个参数字符串拼接成一个字符串进行sha1加密
        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();
        }

        // 3.将sha1加密后的字符串可与signature对比,标识该请求来源于微信
        return tmpStr != null ? tmpStr.equals(signature.toUpperCase()) : false;
    }

    private static String byteToStr(byte[] byteArray) {
        StringBuilder strDigest = new StringBuilder();
        for (int i = 0; i < byteArray.length; i++) {
            strDigest.append(byteToHexStr(byteArray[i]));
        }
        return strDigest.toString();
    }

    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;
    }
}
AccessToken简介

到这里,微信就能跟你进行通信了,然后我们再说说AccessToken这个东西,AccessToken在公众号的文档中说的很清楚了,放上地址:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html。看这个就够了,简单来说,调用微信的接口都需要传递AccessToken。官方文档建议我们通过主动和被动来获取AccessToken,主动的话就是用定时任务来刷新AccessToken,被动就是当AccessToken过期的时候,在业务中维护获取AccessToken的方法。文档上说AccessToken过期时间是两个小时,但是以我实际开发中遇到的问题来说,定时任务每5min去刷新就好,不要卡在2个小时这个时间点上。而且AccessToken是全局唯一的,在缓存中存一份就好,如果没有测试公众号,那么这个缓存要在任意环境都能访问,不论是prod还是Dev还是gray。

处理用户操作回调消息

接下来,当用户对该公众号的任何操作,都会由微信通过POST的回调接口回调消息给你,也就是我们登陆controller中的/callback POST接口,你只需要在业务代码中对应处理就好,这里包含了用户登录业务,大致代码如下:

import com.alibaba.fastjson.JSONObject;
import weixin.popular.bean.user.User;
import weixin.popular.api.UserAPI;

@Override
    public void callBack(InputStream inputStream, PrintWriter printWriter,
                         String signature, String timestamp, String nonce) throws Exception {
        logger.info("callback被调用");
        Map messageMap = MessageUtil.parseXmlCrypt(inputStream,
                MessageUtil.getWXBizMsgCrypt(subscriptionToken, subscriptionEncodingAesKey, subscriptionAppid),
                signature, timestamp, nonce);
        logger.info("messageMap为:" + JSONObject.toJSONString(messageMap));
        ReceiveMessageDTO receiveMessageDTO = MessageUtil.mapToBean(messageMap);
        logger.info("参数为,receiveMessageDO:" + JSONObject.toJSONString(receiveMessageDTO));
        // 根据openID,请求微信获取用户信息
        String accessToken = youGetAccessTokenMethod();
                User user = UserAPI.userInfo(accessToken, subscriptionOpenId);
        logger.info("从微信获取用户的数据为:" + JSONObject.toJSONString(user));
        String responseMessage = "";
                // 简单展示两个类型消息,具体消息类型可以看文档
        try {
            switch (receiveMessageDTO.getMsgType()) {
                case MessageUtil.MESSAGE_EVENT:
                    logger.info("进入event事件");
                    // 用户关注公众号是event事件,这里处理用户关注公众号或者已关注扫码登录的逻辑,具体处理根据自己业务来定
                    // 当业务成功的获取到了用户的信息时,可以以sceneStr为key,把用户信息放入缓存中,
                    // 在/check-login中给到前端,这时前端知道用户已经登录了,就可以跳转到业务页面了
                        
                    // saveUserAndSaveUserTokenInCache();
                    break;
                case MessageUtil.MESSAGE_TEXT:
                    logger.info("进入text事件");
                    break;
                default:
                    responseMessage = "";
            }
        }catch (Exception e) {
            responseMessage = "";
            logger.error("微信公众号回调异常", e);
        }
        // 回复信息给到关注 & 登录者
        logger.info("回复的消息为:" + responseMessage);
        // 消息加密,如果消息不为空,则回复,如果消息为空,不回复
        if (responseMessage == null) {
            responseMessage = "";
        }
        responseMessage = MessageUtil.getWXBizMsgCrypt(subscriptionToken, subscriptionEncodingAesKey, subscriptionAppid)
                .encryptMsg(responseMessage, timestamp, nonce);
        logger.info("加密后的消息为:" + responseMessage);
        try {
            printWriter.print(responseMessage);
        } catch (Exception e) {
            throw e;
        } finally {
            if (printWriter != null) {
                printWriter.close();
            }
        }
    }

其中工具类和DTO的代码如下:

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class ReceiveMessageDTO {
    // 开发者微信号
    private String toUserName;

    // 发送方openID
    private String fromUserName;

    private Integer createTime;

    private String msgType;

    private String event;

    private String content;

    // 事件key值
    private String eventKey;

    private String ticket;
}

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

/**
 * @author luoling
 * @Description 这里必须要首字母大写,需要转成xml
 * @date 2020-04-27 16:19
 */
@Getter
@Setter
@ToString
public class SendTextMessageDTO {
    private String ToUserName;

    private String FromUserName;

    private Integer CreateTime;

    private String MsgType;

    private String Content;
}

import com.qq.weixin.mp.aes.WXBizMsgCrypt;
import com.thoughtworks.xstream.XStream;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import weixin.popular.bean.message.templatemessage.TemplateMessageItem;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * @author luoling
 * @date 2020-04-27 14:52
 */
public class MessageUtil {
    public static final String MESSAGE_TEXT = "text";

    public static final String MESSAGE_IMAGE = "image";

    public static final String MESSAGE_VOICE = "voice";

    public static final String MESSAGE_VIDEO = "video";

    public static final String MESSAGE_SHORTVIDEO = "shortvideo";

    public static final String MESSAGE_LINK = "link";

    public static final String MESSAGE_LOCATION = "location";

    public static final String MESSAGE_EVENT = "event";

    public static final String MESSAGE_SUBSCRIBE = "subscribe";

    public static final String MESSAGE_UNSUBSCRIBE = "unsubscribe";

    public static final String MESSAGE_CLICK = "CLICK";

    public static final String MESSAGE_VIEW = "VIEW";

    public static final String MESSAGE_SCAN = "SCAN";

    public static final String MENU_CLICK = "click";

    public static final String MENU_MINIPROGRAM = "miniprogram";

    public static String initText(String toUserName, String fromUserName, String content) {
        SendTextMessageDTO message = new SendTextMessageDTO();
        // 接收方openID
        message.setToUserName(toUserName);
        // 开发者微信号
        message.setFromUserName(fromUserName);
        message.setMsgType(MESSAGE_TEXT);
        message.setContent(content);
        message.setCreateTime((int) (System.currentTimeMillis() / 1000));
        return objectToXml(message);
    }

    public static ReceiveMessageDTO mapToBean(Map map) {
        ReceiveMessageDTO receiveMessageDTO = new ReceiveMessageDTO();
        receiveMessageDTO.setEvent(map.get("Event"));
        receiveMessageDTO.setFromUserName(map.get("FromUserName"));
        receiveMessageDTO.setToUserName(map.get("ToUserName"));
        receiveMessageDTO.setMsgType(map.get("MsgType"));
        receiveMessageDTO.setContent(map.get("Content"));
        receiveMessageDTO.setCreateTime(Integer.valueOf(map.get("CreateTime")));
        String eventKey = map.get("EventKey");
        if (eventKey != null) {
            receiveMessageDTO.setEventKey(eventKey.replace("qrscene_", ""));
        }
        receiveMessageDTO.setTicket(map.get("Ticket"));
        return receiveMessageDTO;
    }

    /*将我们的消息内容转变为xml*/
    private static String objectToXml(SendTextMessageDTO message) {
        XStream xStream = new XStream();
        //xml根节点替换成 默认是Message的包名
        xStream.alias("xml", message.getClass());
        return xStream.toXML(message);
    }
    
    public static Map parseXmlCrypt(InputStream inputStream, WXBizMsgCrypt wxCeypt,
                                                    String msgSignature, String timestamp, String nonce) throws Exception {
        // 将解析结果存储在HashMap中
        Map map = new HashMap<>();

        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        StringBuffer buf = new StringBuffer();
        while ((line = reader.readLine()) != null) {
            buf.append(line);
        }
        reader.close();
        inputStream.close();

        String respXml = wxCeypt.decryptMsg(msgSignature, timestamp, nonce, buf.toString());

        //SAXReader reader = new SAXReader();
        Document document = DocumentHelper.parseText(respXml);
        // 得到xml根元素
        Element root = document.getRootElement();
        // 得到根元素的所有子节点
        List elementList = root.elements();

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

        return map;
    }

    public static WXBizMsgCrypt getWXBizMsgCrypt(String token, String encodingAesKey, String appid) throws Exception {
        return new WXBizMsgCrypt(token, encodingAesKey, appid);
    }

    public static LinkedHashMap buildMessageDataMap(String first, String remark, String... keywords) {
        LinkedHashMap dataMap = new LinkedHashMap<>();
        dataMap.put("first", new TemplateMessageItem(first, null));
        Integer count = 1;
        for (String keyword : keywords) {
            dataMap.put("keyword" + count++, new TemplateMessageItem(keyword, null));
        }
        dataMap.put("remark", new TemplateMessageItem(remark, null));
        return dataMap;
    }
}
前端如何处理

到这里,我们已经处理了用户扫码---->点击关注公众号按钮---->后端执行登录这个流程,接下来就是前端的处理了,前端这边其实很简单,只需要先调用后端登录接口中的/ticket接口获取登录二维码,然后定时轮询:/check-login这个接口就行了,两个接口实现如下:

import weixin.popular.bean.qrcode.QrcodeTicket;
import weixin.popular.api.*;
import java.net.URI;
import java.net.URLEncoder;
    // sceneStr是前端生成的随机唯一字符串
    @Override
    public String getTicket(String sceneStr) throws Exception {
        // 自己编写获取AccessToken方法
        String accessToken = getAccessTokenFromYouCache();
        // expireSeconds这里我写的是1min,根据自己业务修改,到期二维码就失效
        QrcodeTicket qrcodeTicket = QrcodeAPI.qrcodeCreateTemp(accessToken, expireSeconds, sceneStr);
        if (qrcodeTicket == null || !qrcodeTicket.isSuccess() || StringUtils.isBlank(qrcodeTicket.getUrl())) {
            logger.info("获取临时二维码报错,json为:" + JSONObject.toJSONString(qrcodeTicket) + ",AccessToken为:" + accessToken);
            // 自定义ErrorCodeEnum,自己替换成自己的或者去掉
            throw new IllegalArgumentException(ErrorCodeEnum.WX06.getCode());
        }
        // subscriptionTicketUrl为:https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=,建议维护在配置文件中
        return subscriptionTicketUrl + URLEncoder.encode(qrcodeTicket.getTicket(), "utf-8");
    }
    
    @Override
    public UserInfoDTO checkLoginReturnToken(String sceneStr) {
        // 从缓存中获取用户信息
        getUserInfoFromCache();
        // 把缓存中的数据转化成自定义的DTO给到前端,UserInfoDTO就是自定义DTO
    }

总结及相应的流程图

好了,到这里,整个流程就结束了,我们再来总结下,整个业务流程以接口为维度大致如下图所示:


图二

最后在放上相应的二维码和微信回调消息文档:

获取带参数的二维码:https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html

接收事件推送:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html

整个代码基于业务有删减,如果哪里删出了问题欢迎留言跟我说。

你可能感兴趣的:(Java公众号登录)