手把手教你用 SpringBoot 开发微信公众号后台

[TOC]

Hello 各位小伙伴,松哥今天要和大家聊一个有意思的话题,就是使用 Spring Boot 开发微信公众号后台。

很多小伙伴可能注意到松哥的微信公众号后台有一个回复关键字如 666 或者 888 可以获取学习资料的功能,这是松哥基于 Spring Boot 写的一个简单后台,今天我们就来简单聊聊这个如何实现。

1. 实现思路

其实松哥这个回复关键字获取学习资料实现原理很简单,说白了,就是一个数据查询操作而已,回复的口令是查询关键字,回复的内容则是查询结果。这个原理很简单。

另一方面大家需要明白微信公众号后台开发消息发送的一个流程,大家看下面这张图:

这是大家在公众号后台回复关键字的情况。那么这个消息是怎么样一个传递流程呢?我们来看看下面这张图:

这张图,我给大家稍微解释下:

  1. 首先 javaboy4096 这个字符从公众号上发送到了微信服务器
  2. 接下来微信服务器会把 javaboy4096 转发到我自己的服务器上
  3. 我收到 javaboy4096 这个字符之后,就去数据库中查询,将查询的结果,按照腾讯要求的 XML 格式进行返回
  4. 微信服务器把从我的服务器收到的信息,再发回到微信上,于是小伙伴们就看到了返回结果了

大致的流程就是这个样子。

接下来我们就来看一下实现细节。

2. 公众号后台配置

开发的第一步,是微信服务器要验证我们自己的服务器是否有效。

首先我们登录微信公众平台官网后,在公众平台官网的 开发-基本设置 页面,勾选协议成为开发者,然后点击“修改配置”按钮,填写:

  • 服务器地址(URL)
  • Token
  • EncodingAESKey

这里的 URL 配置好之后,我们需要针对这个 URL 开发两个接口,一个是 GET 请求的接口,这个接口用来做服务器有效性验证,另一个则是 POST 请求的接口,这个用来接收微信服务器发送来的消息。也就是说,微信服务器的消息都是通过 POST 请求发给我的。

Token 可由开发者可以任意填写,用作生成签名(该 Token 会和接口 URL 中包含的 Token 进行比对,从而验证安全性)。

EncodingAESKey 由开发者手动填写或随机生成,将用作消息体加解密密钥。

同时,开发者可选择消息加解密方式:明文模式、兼容模式和安全模式。明文模式就是我们自己的服务器收到微信服务器发来的消息是明文字符串,直接就可以读取并且解析,安全模式则是我们收到微信服务器发来的消息是加密的消息,需要我们手动解析后才能使用。

3. 开发

公众号后台配置完成后,接下来我们就可以写代码了。

3.1 服务器有效性校验

我们首先来创建一个普通的 Spring Boot 项目,创建时引入 spring-boot-starter-web 依赖,项目创建成功后,我们创建一个 Controller ,添加如下接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/verify_wx_token")
public void login(HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException {
request.setCharacterEncoding("UTF-8");
String signature = request.getParameter("signature");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String echostr = request.getParameter("echostr");
PrintWriter out = null;
try {
out = response.getWriter();
if (CheckUtil.checkSignature(signature, timestamp, nonce)) {
out.write(echostr);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
out.close();
}
}

关于这段代码,我做如下解释:

  1. 首先通过 request.getParameter 方法获取到微信服务器发来的 signature、timestamp、nonce 以及 echostr 四个参数,这四个参数中:signature 表示微信加密签名,signature 结合了开发者填写的 token 参数和请求中的timestamp参数、nonce参数;timestamp 表示时间戳;nonce 表示随机数;echostr 则表示一个随机字符串。
  2. 开发者通过检验 signature 对请求进行校验,如果确认此次 GET 请求来自微信服务器,则原样返回 echostr 参数内容,则接入生效,成为开发者成功,否则接入失败。
  3. 具体的校验就是松哥这里的 CheckUtil.checkSignature 方法,在这个方法中,首先将token、timestamp、nonce 三个参数进行字典序排序,然后将三个参数字符串拼接成一个字符串进行 sha1 加密,最后开发者获得加密后的字符串可与 signature 对比,标识该请求来源于微信。

校验代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class CheckUtil {
private static final String token = "123456";
public static boolean checkSignature(String signature, String timestamp, String nonce) {
String[] str = new String[]{token, timestamp, nonce};
//排序
Arrays.sort(str);
//拼接字符串
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < str.length; i++) {
buffer.append(str[i]);
}
//进行sha1加密
String temp = SHA1.encode(buffer.toString());
//与微信提供的signature进行匹对
return signature.equals(temp);
}
}
public class SHA1 {
private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5',
'6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
private static String getFormattedText(byte[] bytes) {
int len = bytes.length;
StringBuilder buf = new StringBuilder(len * 2);
for (int j = 0; j < len; j++) {
buf.append(HEX_DIGITS[(bytes[j] >> 4) & 0x0f]);
buf.append(HEX_DIGITS[bytes[j] & 0x0f]);
}
return buf.toString();
}
public static String encode(String str) {
if (str == null) {
return null;
}
try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA1");
messageDigest.update(str.getBytes());
return getFormattedText(messageDigest.digest());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

OK,完成之后,我们的校验接口就算是开发完成了。接下来就可以开发消息接收接口了。

3.2 消息接收接口

接下来我们来开发消息接收接口,消息接收接口和上面的服务器校验接口地址是一样的,都是我们一开始在公众号后台配置的地址。只不过消息接收接口是一个 POST 请求。

我在公众号后台配置的时候,消息加解密方式选择了明文模式,这样我在后台收到的消息直接就可以处理了。微信服务器给我发来的普通文本消息格式如下:

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[this is a test]]></Content>
<MsgId>1234567890123456</MsgId>
</xml>

这些参数含义如下:

参数 描述
ToUserName 开发者微信号
FromUserName 发送方帐号(一个OpenID)
CreateTime 消息创建时间 (整型)
MsgType 消息类型,文本为text
Content 文本消息内容
MsgId 消息id,64位整型

看到这里,大家心里大概就有数了,当我们收到微信服务器发来的消息之后,我们就进行 XML 解析,提取出来我们需要的信息,去做相关的查询操作,再将查到的结果返回给微信服务器。

这里我们先来个简单的,我们将收到的消息解析并打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@PostMapping("/verify_wx_token")
public void handler(HttpServletRequest request, HttpServletResponse response) throws Exception {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
Map<String, String> parseXml = MessageUtil.parseXml(request);
String msgType = parseXml.get("MsgType");
String content = parseXml.get("Content");
String fromusername = parseXml.get("FromUserName");
String tousername = parseXml.get("ToUserName");
System.out.println(msgType);
System.out.println(content);
System.out.println(fromusername);
System.out.println(tousername);
}
public static Map<String, String> parseXml(HttpServletRequest request) throws Exception {
Map<String, String> map = new HashMap<String, String>();
InputStream inputStream = request.getInputStream();
SAXReader reader = new SAXReader();
Document document = reader.read(inputStream);
Element root = document.getRootElement();
List<Element> elementList = root.elements();
for (Element e : elementList)
map.put(e.getName(), e.getText());
inputStream.close();
inputStream = null;
return map;
}

大家看到其实都是一些常规代码,没有什么难度。

做完这些之后,我们将项目打成 jar 包在服务器上部署启动。启动成功之后,确认微信的后台配置也没问题,我们就可以在公众号上发一条消息了,这样我们自己的服务端就会打印出来刚刚消息的信息。

4. 消息分类

在讨论如何给微信服务器回复消息之前,我们需要先来了解下微信服务器发来的消息主要有哪些类型以及我们回复给微信的消息都有哪些类型。

在前文中大家了解到,微信发送来的 xml 消息中有一个 MsgType 字段,这个字段就是用来标记消息的类型。这个类型可以标记出这条消息是普通消息还是事件消息还是图文消息等。

普通消息主要是指:

  • 文本消息
  • 图片消息
  • 语音消息
  • 视频消息
  • 小视频消息
  • 地址位置消息
  • 链接消息

不同的消息类型,对应不同的 MsgType,这里我还是以普通消息为例,如下:

消息类型 MsgType
文本消息 text
图片消息 image
语音消息 voice
视频消息 video
小视频消息 shortvideo
地址位置消息 location
链接消息 link

大家千万不要以为不同类型消息的格式是一样的,其实是不一样的,也就是说,MsgType 为 text 的消息和 MsgType 为 image 的消息,微信服务器发给我们的消息内容是不一样的,这样带来一个问题就是我无法使用一个 Bean 去接收不同类型的数据,因此这里我们一般使用 Map 接收即可。

这是消息的接收,除了消息的接收之外,还有一个消息的回复,我们回复的消息也有很多类型,可以回复普通消息,也可以回复图片消息,回复语音消息等,不同的回复消息我们可以进行相应的封装。因为不同的返回消息实例也是有一些共同的属性的,例如消息是谁发来的,发给谁,消息类型,消息 id 等,所以我们可以将这些共同的属性定义成一个父类,然后不同的消息再去继承这个父类。

5. 返回消息类型定义

首先我们来定义一个公共的消息类型:

1
2
3
4
5
6
7
8
public class BaseMessage {
private String ToUserName;
private String FromUserName;
private long CreateTime;
private String MsgType;
private long MsgId;
//省略 getter/setter
}

在这里:

  • ToUserName 表示开发者的微信号
  • FromUserName 表示发送方账号(用户的 OpenID)
  • CreateTime 消息的创建时间
  • MsgType 表示消息的类型
  • MsgId 表示消息 id

这是我们的基本消息类型,就是说,我们返回给用户的消息,无论是什么类型的消息,都有这几个基本属性。然后在此基础上,我们再去扩展出文本消息、图片消息 等。

我们来看下文本消息的定义:

1
2
3
4
public class TextMessage extends BaseMessage {
private String Content;
//省略 getter/setter
}

文本消息在前面消息的基础上多了一个 Content 属性,因此文本消息继承自 BaseMessage ,再额外添加一个 Content 属性即可。

其他的消息类型也是类似的定义,我就不一一列举了,至于其他消息的格式,大家可以参考微信开放文档(http://1t.click/aPXK)。

6. 返回消息生成

消息类型的 Bean 定义完成之后,接下来就是将实体类生成 XML。

首先我们定义一个消息工具类,将常见的消息类型枚举出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* 返回消息类型:文本
*/
public static final String RESP_MESSAGE_TYPE_TEXT = "text";
/**
* 返回消息类型:音乐
*/
public static final String RESP_MESSAGE_TYPE_MUSIC = "music";
/**
* 返回消息类型:图文
*/
public static final String RESP_MESSAGE_TYPE_NEWS = "news";
/**
* 返回消息类型:图片
*/
public static final String RESP_MESSAGE_TYPE_Image = "image";
/**
* 返回消息类型:语音
*/
public static final String RESP_MESSAGE_TYPE_Voice = "voice";
/**
* 返回消息类型:视频
*/
public static final String RESP_MESSAGE_TYPE_Video = "video";
/**
* 请求消息类型:文本
*/
public static final String REQ_MESSAGE_TYPE_TEXT = "text";
/**
* 请求消息类型:图片
*/
public static final String REQ_MESSAGE_TYPE_IMAGE = "image";
/**
* 请求消息类型:链接
*/
public static final String REQ_MESSAGE_TYPE_LINK = "link";
/**
* 请求消息类型:地理位置
*/
public static final String REQ_MESSAGE_TYPE_LOCATION = "location";
/**
* 请求消息类型:音频
*/
public static final String REQ_MESSAGE_TYPE_VOICE = "voice";
/**
* 请求消息类型:视频
*/
public static final String REQ_MESSAGE_TYPE_VIDEO = "video";
/**
* 请求消息类型:推送
*/
public static final String REQ_MESSAGE_TYPE_EVENT = "event";
/**
* 事件类型:subscribe(订阅)
*/
public static final String EVENT_TYPE_SUBSCRIBE = "subscribe";
/**
* 事件类型:unsubscribe(取消订阅)
*/
public static final String EVENT_TYPE_UNSUBSCRIBE = "unsubscribe";
/**
* 事件类型:CLICK(自定义菜单点击事件)
*/
public static final String EVENT_TYPE_CLICK = "CLICK";
/**
* 事件类型:VIEW(自定义菜单 URl 视图)
*/
public static final String EVENT_TYPE_VIEW = "VIEW";
/**
* 事件类型:LOCATION(上报地理位置事件)
*/
public static final String EVENT_TYPE_LOCATION = "LOCATION";
/**
* 事件类型:LOCATION(上报地理位置事件)
*/
public static final String EVENT_TYPE_SCAN = "SCAN";

大家注意这里消息类型的定义,以 RESP 开头的表示返回的消息类型,以 REQ 表示微信服务器发来的消息类型。然后在这个工具类中再定义两个方法,用来将返回的对象转换成 XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static String textMessageToXml(TextMessage textMessage) {
xstream.alias("xml", textMessage.getClass());
return xstream.toXML(textMessage);
}
private static XStream xstream = new XStream(new XppDriver() {
public HierarchicalStreamWriter createWriter(Writer out) {
return new PrettyPrintWriter(out) {
boolean cdata = true;
@SuppressWarnings("rawtypes")
public void startNode(String name, Class clazz) {
super.startNode(name, clazz);
}
protected void writeText(QuickWriter writer, String text) {
if (cdata) {
writer.write("<![CDATA[");
writer.write(text);
writer.write("]]>");
} else {
writer.write(text);
}
}
};
}
});

textMessageToXML 方法用来将 TextMessage 对象转成 XML 返回给微信服务器,类似的方法我们还需要定义 imageMessageToXml、voiceMessageToXml 等,不过定义的方式都基本类似,我就不一一列出来了。

7. 返回消息分发

由于用户发来的消息可能存在多种情况,我们需要分类进行处理,这个就涉及到返回消息的分发问题。因此我在这里再定义一个返回消息分发的工具类,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class MessageDispatcher {
public static String processMessage(Map<String, String> map) {
String openid = map.get("FromUserName"); //用户 openid
String mpid = map.get("ToUserName"); //公众号原始 ID
if (map.get("MsgType").equals(MessageUtil.REQ_MESSAGE_TYPE_TEXT)) {
//普通文本消息
TextMessage txtmsg = new TextMessage();
txtmsg.setToUserName(openid);
txtmsg.setFromUserName(mpid);
txtmsg.setCreateTime(new Date().getTime());
txtmsg.setMsgType(MessageUtil.RESP_MESSAGE_TYPE_TEXT);
txtmsg.setContent("这是返回消息");
return MessageUtil.textMessageToXml(txtmsg);
}
return null;
}
public String processEvent(Map<String, String> map) {
//在这里处理事件
}
}

这里我们还可以多加几个 elseif 去判断不同的消息类型,我这里因为只有普通文本消息,所以一个 if 就够用了。

在这里返回值我写死了,实际上这里需要根据微信服务端传来的 Content 去数据中查询,将查询结果返回,数据库查询这一套相信大家都能搞定,我这里就不重复介绍了。

最后在消息接收 Controller 中调用该方法,如下:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping(value = "/verify_wx_token",produces = "application/xml;charset=utf-8")
public String handler(HttpServletRequest request, HttpServletResponse response) throws Exception {
request.setCharacterEncoding("UTF-8");
Map<String, String> map = MessageUtil.parseXml(request);
String msgType = map.get("MsgType");
if (MessageUtil.REQ_MESSAGE_TYPE_EVENT.equals(msgType)) {
return messageDispatcher.processEvent(map);
}else{
return messageDispatcher.processMessage(map);
}
}

在 Controller 中,我们首先判断消息是否是事件,如果是事件,进入到事件处理通道,如果不是事件,则进入到消息处理通道。

注意,这里需要配置一下返回消息的编码,否则可能会出现中文乱码。

如此之后,我们的服务器就可以给公众号返回消息了。

好了,本文我们就先说到这里。

最后再来送一波书吧,《Spring Boot+Vue 全栈开发实战》六本,小伙伴们留言说说你为什么需要这本书,我会从留言的小伙伴中选出来六位幸运小伙伴,包邮到家。

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×