背景
很久沒(méi)寫(xiě)博客了,寫(xiě)點(diǎn)跟公眾號(hào)相關(guān)的吧,相信大家一定都見(jiàn)過(guò),一個(gè)網(wǎng)站,點(diǎn)擊登錄按鈕,會(huì)出現(xiàn)微信掃碼登錄或者手機(jī)賬號(hào)密碼登錄,而點(diǎn)擊微信掃碼登錄,會(huì)出現(xiàn)一張二維碼,掃描這個(gè)二維碼,然后跳轉(zhuǎn)到相應(yīng)的公眾號(hào),點(diǎn)擊關(guān)注之后才能登錄成功,這樣能很好的給公眾號(hào)進(jìn)行導(dǎo)流,這里我們說(shuō)的就是微信掃碼,跳轉(zhuǎn)到公眾號(hào),關(guān)注之后再進(jìn)行登錄。
先叨叨兩句
這里先放上官方文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html,個(gè)人認(rèn)為公眾號(hào)文檔還是寫(xiě)的比小程序好的。開(kāi)始之前建議先通讀下這三個(gè)tab的文檔:
然后我們?nèi)ノ⑿殴娞?hào)設(shè)置頁(yè)面:開(kāi)發(fā)->基本配置下設(shè)置開(kāi)發(fā)者密碼,把你本地和開(kāi)發(fā)環(huán)境IP白名單配置好,然后再去設(shè)置服務(wù)器配置:填寫(xiě)服務(wù)器回調(diào)地址,令牌,消息加密秘鑰,消息加密方式為:安全模式,這一步配置微信會(huì)回調(diào)你的配置的接口,所以要先準(zhǔn)備好回調(diào)接口,不然無(wú)法成功。準(zhǔn)備好之后,下面進(jìn)入開(kāi)發(fā)。
需要的額外包
因?yàn)樯弦黄恼麓蠹叶颊f(shuō)jar包不知道是哪個(gè),這次我都詳細(xì)列出來(lái)了,其實(shí)只要稍微用點(diǎn)心應(yīng)該是知道的,重要的是思路,而不是只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>
配置文件
在你的項(xiàng)目的全局配置文件.yml中加入如下配置:
wechat:
subscription:
auth:
appid: 你的APPID
secret: 你的secret
token: 你的token
encodingAesKey: 你的消息加密秘鑰
正式開(kāi)始
寫(xiě)一個(gè)登錄的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 = "微信公眾號(hào)登錄相關(guān)接口")
@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 = "微信回調(diào)", httpMethod = "GET")
@GetMapping("/callback")
public void checkWechat(HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.info("進(jìn)入回調(diào)get方法");
// 微信加密簽名
String signature = request.getParameter("signature");
// 時(shí)間戳
String timestamp = request.getParameter("timestamp");
// 隨機(jī)數(shù)
String nonce = request.getParameter("nonce");
// 隨機(jī)字符串
String echostr = request.getParameter("echostr");
PrintWriter out = response.getWriter();
logger.info("參數(shù)為,signature:" + signature + ",timestamp:" +
timestamp + ",nonce" + nonce + ",echostr" + echostr);
// 通過(guò)檢驗(yàn)signature對(duì)請(qǐng)求進(jìn)行校驗(yàn),若校驗(yàn)成功則原樣返回echostr,表示接入成功,否則接入失敗
if (SignUtil.checkSignature(signature, timestamp, nonce, token)) {
logger.info("驗(yàn)簽成功");
out.print(echostr);
}
out.close();
}
@ApiOperation(value = "微信回調(diào)", httpMethod = "POST")
@PostMapping("/callback")
public void callBack(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 微信加密簽名
String signature = request.getParameter("msg_signature");
// 時(shí)間戳
String timestamp = request.getParameter("timestamp");
// 隨機(jī)數(shù)
String nonce = request.getParameter("nonce");
wechatService.callBack(request.getInputStream(), response.getWriter(), signature, timestamp, nonce);
}
}
這里我們可以看到最后有兩個(gè)/callback接口,一個(gè)是get請(qǐng)求,用于文章開(kāi)頭所說(shuō)的設(shè)置服務(wù)器配置,所以這個(gè)接口要先發(fā)布上線(xiàn),才能配置成功,另一個(gè)是post請(qǐng)求,這個(gè)就是用于公眾號(hào)接收到用戶(hù)的動(dòng)作之后回調(diào)我們的接口。另外兩個(gè)接口暫時(shí)不用管,我們回過(guò)頭再說(shuō)。其中:signutil工具類(lèi)的代碼為:
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三個(gè)參數(shù)進(jìn)行字典序排序
String[] arr = new String[]{token, timestamp, nonce};
Arrays.sort(arr);
// 2. 將三個(gè)參數(shù)字符串拼接成一個(gè)字符串進(jìn)行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");
// 將三個(gè)參數(shù)字符串拼接成一個(gè)字符串進(jìn)行sha1加密
byte[] digest = md.digest(content.toString().getBytes());
tmpStr = byteToStr(digest);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
// 3.將sha1加密后的字符串可與signature對(duì)比,標(biāo)識(shí)該請(qǐng)求來(lái)源于微信
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簡(jiǎn)介
到這里,微信就能跟你進(jìn)行通信了,然后我們?cè)僬f(shuō)說(shuō)AccessToken這個(gè)東西,AccessToken在公眾號(hào)的文檔中說(shuō)的很清楚了,放上地址:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html。看這個(gè)就夠了,簡(jiǎn)單來(lái)說(shuō),調(diào)用微信的接口都需要傳遞AccessToken。官方文檔建議我們通過(guò)主動(dòng)和被動(dòng)來(lái)獲取AccessToken,主動(dòng)的話(huà)就是用定時(shí)任務(wù)來(lái)刷新AccessToken,被動(dòng)就是當(dāng)AccessToken過(guò)期的時(shí)候,在業(yè)務(wù)中維護(hù)獲取AccessToken的方法。文檔上說(shuō)AccessToken過(guò)期時(shí)間是兩個(gè)小時(shí),但是以我實(shí)際開(kāi)發(fā)中遇到的問(wèn)題來(lái)說(shuō),定時(shí)任務(wù)每5min去刷新就好,不要卡在2個(gè)小時(shí)這個(gè)時(shí)間點(diǎn)上。而且AccessToken是全局唯一的,在緩存中存一份就好,如果沒(méi)有測(cè)試公眾號(hào),那么這個(gè)緩存要在任意環(huán)境都能訪(fǎng)問(wèn),不論是prod還是Dev還是gray。
處理用戶(hù)操作回調(diào)消息
接下來(lái),當(dāng)用戶(hù)對(duì)該公眾號(hào)的任何操作,都會(huì)由微信通過(guò)POST的回調(diào)接口回調(diào)消息給你,也就是我們登陸controller中的/callback POST接口,你只需要在業(yè)務(wù)代碼中對(duì)應(yīng)處理就好,這里包含了用戶(hù)登錄業(yè)務(wù),大致代碼如下:
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被調(diào)用");
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("參數(shù)為,receiveMessageDO:" + JSONObject.toJSONString(receiveMessageDTO));
// 根據(jù)openID,請(qǐng)求微信獲取用戶(hù)信息
String accessToken = youGetAccessTokenMethod();
User user = UserAPI.userInfo(accessToken, subscriptionOpenId);
logger.info("從微信獲取用戶(hù)的數(shù)據(jù)為:" + JSONObject.toJSONString(user));
String responseMessage = "";
// 簡(jiǎn)單展示兩個(gè)類(lèi)型消息,具體消息類(lèi)型可以看文檔
try {
switch (receiveMessageDTO.getMsgType()) {
case MessageUtil.MESSAGE_EVENT:
logger.info("進(jìn)入event事件");
// 用戶(hù)關(guān)注公眾號(hào)是event事件,這里處理用戶(hù)關(guān)注公眾號(hào)或者已關(guān)注掃碼登錄的邏輯,具體處理根據(jù)自己業(yè)務(wù)來(lái)定
// 當(dāng)業(yè)務(wù)成功的獲取到了用戶(hù)的信息時(shí),可以以sceneStr為key,把用戶(hù)信息放入緩存中,
// 在/check-login中給到前端,這時(shí)前端知道用戶(hù)已經(jīng)登錄了,就可以跳轉(zhuǎn)到業(yè)務(wù)頁(yè)面了
// saveUserAndSaveUserTokenInCache();
break;
case MessageUtil.MESSAGE_TEXT:
logger.info("進(jìn)入text事件");
break;
default:
responseMessage = "";
}
}catch (Exception e) {
responseMessage = "";
logger.error("微信公眾號(hào)回調(diào)異常", e);
}
// 回復(fù)信息給到關(guān)注 & 登錄者
logger.info("回復(fù)的消息為:" + responseMessage);
// 消息加密,如果消息不為空,則回復(fù),如果消息為空,不回復(fù)
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();
}
}
}
其中工具類(lèi)和DTO的代碼如下:
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
@Getter
@Setter
@ToString
public class ReceiveMessageDTO {
// 開(kāi)發(fā)者微信號(hào)
private String toUserName;
// 發(fā)送方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 這里必須要首字母大寫(xiě),需要轉(zhuǎn)成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);
// 開(kāi)發(fā)者微信號(hào)
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;
}
/*將我們的消息內(nèi)容轉(zhuǎn)變?yōu)閤ml*/
private static String objectToXml(SendTextMessageDTO message) {
XStream xStream = new XStream();
//xml根節(jié)點(diǎn)替換成<xml> 默認(rèn)是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 {
// 將解析結(jié)果存儲(chǔ)在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();
// 得到根元素的所有子節(jié)點(diǎn)
List<Element> elementList = root.elements();
// 遍歷所有子節(jié)點(diǎn)
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;
}
}
前端如何處理
到這里,我們已經(jīng)處理了用戶(hù)掃碼---->點(diǎn)擊關(guān)注公眾號(hào)按鈕---->后端執(zhí)行登錄這個(gè)流程,接下來(lái)就是前端的處理了,前端這邊其實(shí)很簡(jiǎn)單,只需要先調(diào)用后端登錄接口中的/ticket接口獲取登錄二維碼,然后定時(shí)輪詢(xún):/check-login這個(gè)接口就行了,兩個(gè)接口實(shí)現(xiàn)如下:
import weixin.popular.bean.qrcode.QrcodeTicket;
import weixin.popular.api.*;
import java.net.URI;
import java.net.URLEncoder;
// sceneStr是前端生成的隨機(jī)唯一字符串
@Override
public String getTicket(String sceneStr) throws Exception {
// 自己編寫(xiě)獲取AccessToken方法
String accessToken = getAccessTokenFromYouCache();
// expireSeconds這里我寫(xiě)的是1min,根據(jù)自己業(yè)務(wù)修改,到期二維碼就失效
QrcodeTicket qrcodeTicket = QrcodeAPI.qrcodeCreateTemp(accessToken, expireSeconds, sceneStr);
if (qrcodeTicket == null || !qrcodeTicket.isSuccess() || StringUtils.isBlank(qrcodeTicket.getUrl())) {
logger.info("獲取臨時(shí)二維碼報(bào)錯(cuò),json為:" + JSONObject.toJSONString(qrcodeTicket) + ",AccessToken為:" + accessToken);
// 自定義ErrorCodeEnum,自己替換成自己的或者去掉
throw new IllegalArgumentException(ErrorCodeEnum.WX06.getCode());
}
// subscriptionTicketUrl為:https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=,建議維護(hù)在配置文件中
return subscriptionTicketUrl + URLEncoder.encode(qrcodeTicket.getTicket(), "utf-8");
}
@Override
public UserInfoDTO checkLoginReturnToken(String sceneStr) {
// 從緩存中獲取用戶(hù)信息
getUserInfoFromCache();
// 把緩存中的數(shù)據(jù)轉(zhuǎn)化成自定義的DTO給到前端,UserInfoDTO就是自定義DTO
}
總結(jié)及相應(yīng)的流程圖
好了,到這里,整個(gè)流程就結(jié)束了,我們?cè)賮?lái)總結(jié)下,整個(gè)業(yè)務(wù)流程以接口為維度大致如下圖所示:
最后在放上相應(yīng)的二維碼和微信回調(diào)消息文檔:
獲取帶參數(shù)的二維碼: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
整個(gè)代碼基于業(yè)務(wù)有刪減,如果哪里刪出了問(wèn)題歡迎留言跟我說(shuō)。