Java公众号登录

背景

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

先叨叨两句

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

图1

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

需要的额外包

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

<dependency>
   <groupId>com.github.liyiorg</groupId>
   <artifactId>weixin-popular</artifactId>
   <version>2.8.28</version>
</dependency>
<dependency>
   <groupId>dom4j</groupId>
   <artifactId>dom4j</artifactId>
   <version>1.6.1</version>
</dependency>
<dependency>
   <groupId>com.thoughtworks.xstream</groupId>
   <artifactId>xstream</artifactId>
   <version>1.4.11.1</version>
</dependency>
<dependency>
   <groupId>com.github.liyiorg</groupId>
   <artifactId>weixin-popular</artifactId>
   <version>2.8.28</version>
</dependency>

配置文件

在你的项目的全局配置文件.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<String, String> 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<String, String> 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根节点替换成<xml> 默认是Message的包名
        xStream.alias("xml", message.getClass());
        return xStream.toXML(message);
    }
    
    public static Map<String, String> parseXmlCrypt(InputStream inputStream, WXBizMsgCrypt wxCeypt,
                                                    String msgSignature, String timestamp, String nonce) throws Exception {
        // 将解析结果存储在HashMap中
        Map<String, String> 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<Element> 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<String, TemplateMessageItem> buildMessageDataMap(String first, String remark, String... keywords) {
        LinkedHashMap<String, TemplateMessageItem> 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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354

推荐阅读更多精彩内容