背景
很久没写博客了,写点跟公众号相关的吧,相信大家一定都见过,一个网站,点击登录按钮,会出现微信扫码登录或者手机账号密码登录,而点击微信扫码登录,会出现一张二维码,扫描这个二维码,然后跳转到相应的公众号,点击关注之后才能登录成功,这样能很好的给公众号进行导流,这里我们说的就是微信扫码,跳转到公众号,关注之后再进行登录。
先叨叨两句
这里先放上官方文档地址:https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html,个人认为公众号文档还是写的比小程序好的。开始之前建议先通读下这三个tab的文档:
然后我们去微信公众号设置页面:开发->基本配置下设置开发者密码,把你本地和开发环境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
整个代码基于业务有删减,如果哪里删出了问题欢迎留言跟我说。