最近公司的客户要求在登录时加上一个流程,就是在输入账号密码之后要求用户关注他们的微信公众号才能进入系统,作为职场新人的我从来没有接触过微信 API ,经历3天的调研和2天的代码终于把功能做了出来。在这里记录一下,顺便和大家分享一下我踩过的坑。由于水平有限,如果有什么错误欢迎指出,但请不要言语谩骂。如果我帮助到了同样是第一次接触微信API的你,希望不要吝啬一个赞,谢谢!!!
开篇之前介绍下我设计的登录流程
在注册微信公众平台的开发者账号之前,您需要准备如下工具
无论是公众平台的正式账号,还是用来开发的测试账号,微信都要求开发者提供一个公网可访问的域名。如果您没有域名并且不打算在功能完成之前购买域名,可以考虑使用NATAPP。这里提供一个教程教您配置macOS下载及配置教程、windows下载及配置教程
在项目上线之前,公司可能不会提供正式账号。微信为我们提供了一个开通了所有接口权限(除了支付)的测试账号,申请地址:戳我申请开发者测试账号
在用到这些代码之前,请先把代码复制到工程中,不用管具体实现,都是一些死代码没必要理解。
public class NetWorkUtil {
public static String httpURLConnectionPOST(String postUrl, QrCodeParam param) {
try {
URL url = new URL(postUrl);
// 1. 将url 以 open方法返回的urlConnection
// 连接强转为HttpURLConnection连接.此时cnnection只是为一个连接对象,待连接中
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// 2. 设置连接输出流为true,默认false (post 请求是以流的方式隐式的传递参数)
connection.setDoOutput(true);
// 3. 设置连接输入流为true
connection.setDoInput(true);
// 4.设置请求方式为post
connection.setRequestMethod("POST");
// 5.post请求缓存设为false
connection.setUseCaches(false);
/*
* 6.设置请求头里面的各个属性 (以下为设置内容的类型,设置为经过urlEncoded编码过的from参数)
* application/xml:xml数据 ,application/json:json对象
* text/html:表单数据
*/
connection.setRequestProperty("Content-Type", "application/json;charset=utf-8");
// 7.建立连接
// (请求未开始,直到connection.getInputStream()方法调用时才发起,以上各个参数设置需在此方法之前进行)
connection.connect();
// 8.创建输入输出流,用于往连接里面输出携带的参数,(输出内容为?后面的内容)
DataOutputStream dataout = new DataOutputStream(connection.getOutputStream());
// 9.入参:json格式
String jsonStr = JSON.toJSONString(param);
// 10.将参数输出到连接
dataout.writeBytes(jsonStr);
// 输出完成后刷新并关闭流
dataout.flush();
dataout.close(); // 重要且易忽略步骤 (关闭流,切记!)
// System.out.println("响应code:"+connection.getResponseCode());
// 连接发起请求,处理服务器响应 (从连接获取到输入流并包装为bufferedReader)
BufferedReader bf = new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"));
String line;
StringBuilder sb = new StringBuilder(); // 用来存储响应数据
// 循环读取流,若不到结尾处
while ((line = bf.readLine()) != null) {
sb.append(line);//若要换行:sb.append(line).append(System.getProperty("line.separator"));
}
bf.close(); // 重要且易忽略步骤 (关闭流,切记!)
connection.disconnect(); // 销毁连接
System.err.println(sb.toString());
return sb.toString();
} catch (Exception e) {
e.printStackTrace();
System.out.println("请求失败:"+e.getMessage());
}
return "";
}
public static String httpURLConnectionGET(String getURL){
try{
URL url = new URL(getURL);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoOutput(true); // 设置该连接是可以输出的
connection.setRequestMethod("GET"); // 设置请求方式
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"));
String line = null;
StringBuilder result = new StringBuilder();
while ((line = br.readLine()) != null) { // 读取数据
result.append(line);
}
connection.disconnect();
return result.toString();
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}
public class MessageUtil {
public static Map<String, String> parseXml(HttpServletRequest req){
Map<String, String> map = new HashMap<>();
try(
InputStream inputStream = req.getInputStream();
){
// 初始化 Dom4j核心 对象
SAXReader reader = new SAXReader();
Document document = reader.read(inputStream);
// 获取根节点
Element rootElement = document.getRootElement();
// 获取所有节点
List<Element> elements = rootElement.elements();
for (Element element : elements) {
// 节点名做为 key, 文本节点做为值
map.put(element.getName(), element.getText());
}
}catch (Exception e){
e.printStackTrace();
}
return map;
}
/**
* 扩展xstream使其支持CDATA
*/
private static XStream xstream = new XStream(new XppDriver() {
public HierarchicalStreamWriter createWriter(Writer out) {
return new PrettyPrintWriter(out) {
// 对所有xml节点的转换都增加CDATA标记
boolean cdata = true;
@SuppressWarnings("unchecked")
public void startNode(String name, Class clazz) {
super.startNode(name, clazz);
}
protected void writeText(QuickWriter writer, String text) {
if (cdata) {
writer.write(");
writer.write(text);
writer.write("]]>");
} else {
writer.write(text);
}
}
};
}
});
/**
* 文本消息对象转换成xml
*
* @param textMessage 文本消息对象
* @return xml
*/
public static String messageToXml(TextMessage textMessage) {
xstream.alias("xml", textMessage.getClass());
return xstream.toXML(textMessage);
}
}
public class EncodingUtil {
public static String sort(String token, String timestamp, String nonce){
String[] str = {token, timestamp, nonce};
Arrays.sort(str);
StringBuilder builder = new StringBuilder();
for (String s : str) {
builder.append(s);
}
return builder.toString();
}
public static String shal(String str) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
digest.update(str.getBytes());
byte messageDigest[] = digest.digest();
StringBuffer hexString = new StringBuffer();
// 字节数组转换为 十六进制 数
for (int i = 0; i < messageDigest.length; i++) {
String shaHex = Integer.toHexString(messageDigest[i] & 0xFF);
if (shaHex.length() < 2) {
hexString.append(0);
}
hexString.append(shaHex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return "";
}
}
第一 步当然是在微信公众平台注册一个账号啦,但是在注册之前,希望你了解一下订阅号和服务号的接口权限说明,以免自己注册的账号不能满足功能要求。
如果已经拥有公众平台的账号了,一定要注意下您注册的是订阅号/服务号而不是小程序,本人就是因为半年之前注册了一个小程序的开发者账号(只是注册一个玩玩,从来没用过),然后第一天登录公众平台后就只显示小程序的接口不显示订阅号/服务号的接口。导致我按照微信官方文档的公众号文档中的说明找半天也找不到对应的接口,气得我问候老马的mother(无能咆哮)。浪费我一天的时间(真是蠢哭了)。
经过本人实验,对于已关注的用户,微信只会在该用户扫描带参数二维码时才会推送给服务器消息。对于未关注用户,在扫码后并不会直接推送给服务器事件消息,只有点击了“关注公众号”才会推送关注事件消息
微信的消息类型有如下几种格式
我们实现的是登录后扫码关注公众号进入系统,最应该关心的就是8和9这两种消息类型
何为带参数二维码?可以看下微信官方的解释,微信开发文档/生成带参数二维码
为了满足用户渠道推广分析和用户帐号绑定等场景的需要,公众平台提供了生成带参数二维码的接口。使用该接口可以获得多个带不同场景值的二维码,用户扫描后,公众号可以接收到事件推送。
举个例子,商家为了推广自己的产品,经常会实行奖励机制:谁推送的我就给谁个回扣。商家如何知道一个新的用户是谁推荐的呢?在微信中,商户就是通过带参数二维码来获知这个新的用户是谁推荐来的了。
带参数二维码可以推送以下两种事件
实现登录功能我们不需要关心二维码的参数,我们使用带参数二维码的主要目的是为了接收已经关注公众号用户的扫码事件,加上关注,我们就能接收两种用户(关注的用户和未关注的用户)的扫码事件了。
在用户输入完账号密码之后跳转到我们的带参数二维码界面,跳转到该界面时,开启一个定时任务,不断的向后端某个接口询问该用户是否已经扫码登录(ajax轮询)。由于每个二维码的ticket都是唯一的,所以可以对应一个客户端用户。被轮询的接口会返回3种状态:
推荐使用 springboot 创建项目,这能够省去配置项目的时间(如何创建 springboot 项目请自行百度)。注意,项目启动端口请设置成 80(http 默认端口) 或者 443(https 默认端口),这是微信官方要求的,如果想用其他端口可以使用Nginx 反向代理(同样的,Nginx使用方法请自行百度)
需要引入如下依赖
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-web
dom4j
dom4j
1.6.1
com.thoughtworks.xstream
xstream
1.4.10
com.alibaba
fastjson
1.2.58
org.springframework.boot
spring-boot-starter-cache
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
请先移步到微信官方文档/获取AccessToken了解其重要性
如果您仔细阅读文档,一定会发现这么一句话:access_token的有效期目前为2个小时,需定时刷新
。这个定时刷新,我是通过一个线程,每隔 2小时 - 20 秒刷新一次 access_token
,提前20秒防止意外情况发生阻塞 access_token
的刷新。并且该线程在 springboot 项目启动后立即启动。具体代码实现如下
@SpringBootApplication
//开启缓存
@EnableCaching
public class DemoApplication {
@Autowired
private WeChatBasicInfo basicInfo;
// 自己的测试号
private static String appID = "你的appID";
private static String appsecret = "你的appsecret";
// 这个类的定义在下面
public static AccessToken accessToken = null;
// 请求 access_token 的接口
private static String uri = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
poll();
}
// 开启获取 access_token 的线程
private static void poll() {
new Thread(() -> {
synchronized (DemoApplication.class) {
try {
System.out.println("=----------------- 获取accesstoken");
while (true) {
uri = String.format(uri, appID, appsecret);
URL url = new URL(uri);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoOutput(true); // 设置该连接是可以输出的
connection.setRequestMethod("GET"); // 设置请求方式
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"));
String line = null;
StringBuilder result = new StringBuilder();
while ((line = br.readLine()) != null) { // 读取数据
result.append(line);
}
connection.disconnect();
String jsonStr = result.toString();
accessToken = JSON.parseObject(jsonStr, AccessToken.class);
// 判断请求是否成功
if (accessToken.getErrcode() == null || accessToken.getErrcode().isEmpty()) {
System.out.println("获取 token 成功 token = " + accessToken.getAccess_token());
// 请求成功等待两小时
Thread.sleep(7000 * 1000);
} else {
System.out.printf("获取 token 失败,请重新获取。错误码 : %s\t请查阅微信公众平台返回码(https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html)", accessToken.getErrcode());
// 3秒后重试一次
Thread.sleep(1000 * 3);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
@Data
public class AccessToken {
private String access_token;
private String expires_in; // 过期时间
private String errcode; // 如果请求出错才会有值
private String errmsg;// 如果请求出错才会有值
}
如果您注册好测试账号后,进入测试账号首页就会看到测试号信息了。
其中 appID相当于用户名,appsecret相当于密码。这两个属性用来换区**access_token**,非常重要,推荐在项目中已常量或者放在配置文件中。
解释下参数
PS : URL就填你用NATAPP进行内网穿透显示的网址 + 你的项目地址即可
在点击提交按钮后,会向我们填写的 URL 发送一个请求,请求中一共有四个参数,官方文档解释传送门
前三个没什么好说的,主要是最后一个。如果通过签名校验通过,原样返回 echostr 。如果没通过什么也不用干。
前提条件:首先要确定你注册的账号是服务号 才能查看到我下面介绍的配置项,同时,你必须是通过微信认证的服务号才能生成带参数的二维码(据说300RMB)。
进行配置:鼠标滚到最下面,可以在左侧导航栏看见开发选项,点击基本配置
可以看到正式号也有 AppID 和 AppSecret
不同的是正式号多了一个 IP 白名单,这个 IP 白名单需要配置本机 公网IP 。(注意,在终端/cmd获取的 IP 不是公网IP)打开百度搜索本机IP 即可获得本机的 公网IP。
服务器配置和测试号的接口配置信息一样,GET请求用来校验签名,POST请求用来处理用户信息/事件推送。
private static final String TOKEN = "你填写的TOKEN";
/**
* 微信服务器校验签名
* @param req
* @param resp
* @throws Exception
*/
@RequestMapping(value = "/wechatLogin/test",method = RequestMethod.GET)
public void login(HttpServletRequest req, HttpServletResponse resp) throws Exception {
System.out.println("-----开始校验签名-----");
// 接收微信服务器发送请求时传递过来的参数
String signature = req.getParameter("signature");
String timestamp = req.getParameter("timestamp");
String nonce = req.getParameter("nonce"); //随机数
String echostr = req.getParameter("echostr");//随机字符串
// 将token、timestamp、nonce三个参数进行字典序排序并拼接为一个字符串
String sortStr = EncodingUtil.sort(TOKEN,timestamp, nonce);
// 字符串进行shal加密
String mySignature = EncodingUtil.shal(sortStr);
// 校验微信服务器传递过来的签名 和 加密后的字符串是否一致, 若一致则签名通过
if (!"".equals(signature) && !"".equals(mySignature) && signature.equals(mySignature)) {
System.out.println("-----签名校验通过-----");
resp.getWriter().write(echostr);
} else {
System.out.println("-----校验签名失败-----");
}
}
启动项目,点击提交按钮,如果一切顺利你将会看到
如果配置失败请打断点查看四个参数和你进行加密之后的结果。
下面的其他配置,例如JS接口安全域名,我也不知道它是干嘛的,就不乱写坑大家了,反正我没用上它。
前面在启动类中开启了一个线程,每隔2小时重新获取一次 access_token
并赋值给一个 static
变量,这样我们就可以在其他类中获取这个 access_token
了。
@Autowired
private RedisService redisService;
/**
* 二维码参数
*/
@Autowired
private QrCodeParam param;
/**
* 获取二维码 ticket
* @param model
* @param session
* @return
*/
@RequestMapping(value = "/testToken", method = RequestMethod.GET)
public String testQrCode(Model model, HttpSession session) {
// 用token换取带参数二维码的 ticket
String access_token = DemoApplication.accessToken.getAccess_token();
String url = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + access_token;
// 获取 ticket 的JSON格式
String result = NetWorkUtil.httpURLConnectionPOST(url, param);
// 将JSON转换成Map
Map map = JSON.parseObject(result, Map.class);
// 获取 ticket
String ticket = map.get("ticket").toString();
model.addAttribute("ticket", result);
Long aLong = Long.valueOf(param.getExpire_seconds());
// 用 ticket 做为一个客户端的key(redis 的 key),值为 null ,只有当用户扫码并关注后才存值
redisService.set(ticket, null, aLong);
return "qrTest.html";
}
下面是 qrTest.html中的定义,ajax 轮询的代码也在其中
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Documenttitle>
head>
<body>
<h3 >token = h3>
<img alt="场景参数二维码" id="qrCode">
<script src="https://cdn.bootcss.com/jquery/2.1.2/jquery.js">script>
<script th:inline="javascript">
// 获取 ticket 并用来获取二维码,这是 thymeleaf 的取值方式,如果你用的 jsp 或者其他模板请注意更换
var ticket = JSON.parse([[${ticket}]]);
$('#qrCode').attr('src','https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket='+ticket.ticket);
setInterval(function(){
console.log('轮询一次');
$.ajax({
type : 'post',
dataType : 'json',
data : {
"ticket":ticket.ticket,
"expireSeconds" : ticket.expire_seconds
},
url : '/qwxLogin/validQrCode',
success : function(response){
console.log('请求成功',response);
if(response.wait){
// 无为而坐
}else if(response.reload){
alert('刷新');
// 重新获取二维码
window.location.reload();
}else if(response.scaned){
alert('扫描完毕');
// 一个测试页面,可以理解为系统主页
window.location.href = "/focus/hello";
}
},
error : function(error){
console.log('请求失败',error);
}
})
// 每隔一秒轮询一次
},1000 * 1)
script>
body>
html>
@Component
@Data
@PropertySource("classpath:wechat.properties")
public class QrCodeParam {
/**
* 该二维码有效时间,以秒为单位。 最大不超过2592000(即30天),此字段如果不填,则默认有效期为30秒。
*/
@Value("${qr.expire_seconds}")
private String expire_seconds;
/**
* 二维码类型,
* QR_SCENE为临时的整型参数值,
* QR_STR_SCENE为临时的字符串参数值,
* QR_LIMIT_SCENE为永久的整型参数值,
* QR_LIMIT_STR_SCENE为永久的字符串参数值
*/
@Value("${qr.action_name}")
private String action_name;
/**
* 二维码场景参数
*/
private Map<String,Object> action_info;
/**
* sessionID,用来区分二维码是哪个客户端的
*/
private String sessionID;
}
这里我把带参数的二维码需要设置的一些参数定义到了配置文件wechat.properties
中,方便以后更改。(如果你不太了解这些字段,请一定要仔细看一下 官方文档)
在测试账号的接口配置信息 的URL或者正式号的服务器配置中的URL既是接收消息/事件推送的接口地址,在Controller中创建一个POST请求即可。
微信服务器会将用户信息/事件推送以 XML 格式的字符串传递到我们配置的接口中,查看所有格式请移步微信公众平台->消息管理。这里只介绍登录需要的 关注/取关、扫描带参数的二维码事件推送的消息。
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>123456789CreateTime>
<MsgType>MsgType>
<Event>Event>
xml>
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>123456789CreateTime>
<MsgType>MsgType>
<Event>Event>
<EventKey>EventKey>
<Ticket>Ticket>
xml>
<xml>
<ToUserName>ToUserName>
<FromUserName>FromUserName>
<CreateTime>123456789CreateTime>
<MsgType>MsgType>
<Event>Event>
<EventKey>EventKey>
<Ticket>Ticket>
xml>
对于 XML 形式的字符串,可以使用上面提供的MessageUtil
中的parseXML
方法进行解析,返回一个Map。
接收微信服务器推送的 事件/用户信息 的POST
接口如下
/**
* 当公众号接收消息时,调用该接口,和上面的验证签名的 mapping 相同,请求方式不同
* @param req
* @param res
* @throws Exception
*/
@RequestMapping(path = "/wechatLogin/test", method = RequestMethod.POST)
public void receivesMessage(HttpServletRequest req, HttpServletResponse res) throws Exception{
// 设置编码,否则返回给用户信息时可能会乱码
res.setCharacterEncoding("UTF-8");
// 解析微信服务器传来的 XML 格式字符串
Map<String, String> stringMap = MessageUtil.parseXml(req);
// 获取消息类型
String msgType = stringMap.get("MsgType");
// 如果是事件推送
if(MessageTypeEnum.REQ_MESSAGE_TYPE_EVENT.getTypeName().equals(msgType)){
// 如果用户已经关注过公众号,就是 SCAN 事件,或者是用户的关注事件
if(EventTypeEnum.AFTER_FOCUS_ON.getEvent().equals(stringMap.get("Event")) || EventTypeEnum.BEFORE_FOCUS_ON.getEvent().equals(stringMap.get("Event"))){
System.err.println("关注或者扫码事件。 xml = " + req.getSession().getId());
// 注意,如果要回复用户消息,FromUserName 和 ToUserName 要调换顺序
String toUser = stringMap.get("FromUserName");
String fromUser = stringMap.get("ToUserName");
// 获取 ticket
String ticket = stringMap.get("Ticket");
// 将用户扫描的二维码的 Ticket作为 key,值为扫码用户的openId. redisService 代码会在后面贴出。
redisService.set(ticket,toUser);
// 临时返回信息 xml 字符串 这里有个%s 这里有个%s
String reply = "12345678 " ;
String format = String.format(reply, toUser, fromUser);
res.getWriter().write(format);
}
// 如果用户发来的文本事件,当然还可能有其他事件。请自行添加其他事件
}else {
System.err.println("文本事件");
String toUser = stringMap.get("FromUserName");
String fromUser = stringMap.get("ToUserName");
String reply = "12345678 " ;
String format = String.format(reply, toUser, fromUser);
res.getWriter().write(format);
}
}
可以看到接收用户的消息/事件推送后给用户返回了一条提示消息,和接收消息一样回复消息也要是微信指定的 XML 格式,具体格式请移步微信公众平台/被动回复用户消息。具体的消息封装成 XML 格式并返回给微信服务器的方法,请查看 这篇教程。
作为一名刚刚参加工作的初级开发攻城狮,当学习了新的知识之后做一个总结是个很好的习惯。记录的同时也能发现之前自己犯的错有多傻。还有,以前百度别人的教程,从来都是 command
+ c
、 command
+v
、command
+ w
完事,从来没想过要写这么久。