微信公众号开发笔记

微信公众号开发时的一些笔记,防止忘记

公众号类型

  • 服务号
  • 订阅号
  • 企业号

区别可以看公众平台服务号、订阅号、企业微信、小程序的相关说明

开发模式与编辑模式

微信公众号有两种模式:

  • 开发模式
  • 编辑模式

两者是互斥的,开启了开发模式,就不能使用编辑模式的功能,开启了编辑模式就不能使用开发模式的功能。作为开发人员,编辑模式用不着,都是使用开发模式

数据流向

在数据的流动过程中,涉及三个角色:

  • 用户
  • 微信服务器
  • 开发者服务器

数据在这三个角色之间流动。一张图来说明

  1. 用户在微信的手机客户端向公众号发送一条消息,这条消息会发送到微信服务器
  2. 微信服务器接收到这条消息之后,再把它转发到开发者的服务器
  3. 开发者服务器根据消息内容,执行一些业务逻辑,然后生成一条回复消息再发送到微信服务器
  4. 微信服务器收到了回复消息,再返回给用户的手机

外网映射工具

为了开发时方便调试,需要一个外网映射工具,把微信服务器的请求转发到自己的开发电脑上,这样就可以方便地调试代码了

工具目前推荐使用natapp,官网有一个一分钟快速使用教程可以看,看了就会用了

公众平台测试账号

如果没有公众号的话,可以申请一个测试用的公众号,这个公众号的权限是最全的,什么功能都能用

文档看这里:微信公众平台技术文档-开始开发-接口测试号申请

开发模式接入

微信公众平台技术文档-开发开发-接入指南有具体说明

在配置的时候,微信服务器会发送一个 HTTP GET 请求到我们的服务器,携带 4 个参数

参数 描述
signature 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。
timestamp 时间戳
nonce 随机数
echostr 随机字符串

需要对这些参数进行校验,步骤如下:

  1. tokentimestampnonce 进行字典排序得到一个字符串
  2. 再把字符串进行 SHA1 加密得到一个新的字符串
  3. 判断新的字符串和 signature 是否相等。如果相等,说明这个请求是来自微信服务器的请求,不是非法请求,于是把 echostr 原样返回

验证参数合法性的的工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.apache.commons.codec.digest.DigestUtils;
import java.util.Arrays;

public static boolean checkSignature(String token, String signature, String timestamp,
String nonce, String echostr) {
String[] parameters = {token, timestamp, nonce};
Arrays.sort(parameters);

StringBuilder str = new StringBuilder();
for (String parameter : parameters) {
str.add(parameter);
}

String sha1Str = DigestUtils.sha1Hex(str.toString());

return signature.equals(sha1Str);
}

SpringMVC 的 Controler 层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
@RequestMapping("wx/portal")
public class WxController {

@GetMapping(produces = "text/plain;charset=utf-8")
@ResponseBody
public String auth(String signature, String timestamp, String nonce, String echostr) {
if (WxUtils.checkSignature(signature, timestamp, nonce)) {
return echostr;
} else {
return "非法请求";
}
}
}

接收用户消息

当用户发送消息到微信服务器时,微信服务器会使用 HTTP POST 转发消息到我们的服务器,URL 和“接入微信服务器”中配置的 URL 时一样的,只是 HTTP 请求方法从 GET 变成了 POST

目前可以接收的用户消息有:

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

可以看这篇文档微信公众平台技术文档-消息管理-接收普通消息

回复用户消息

用户发来消息,我们的服务器就需要回复消息,目前可以回复的消息有:

  • 文本消息
  • 图片消息
  • 语音消息
  • 视频消息
  • 音乐消息
  • 图文消息

可以看这篇文档微信公众平台技术文档-消息管理-被动回复用户消息

一个接收用户文字消息并原样返回的例子

用户给公众号发送一条“Hello”的文字,公众号回复“我收到了你的消息:Hello”

微信服务器会把用户发送的消息使用 HTTP POST 的方式转发到开发者的服务器,所以 Controller 层监听一下 POST 请求就行。同时不要忘了对请求进行合法性验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Controller
@RequestMapping("wx/portal")
public class WxController {

@PostMapping(produces = "application/xml;charset=UTF-8")
@ResponseBody
public String data(@RequestBody String requestBody,
String signature,
String timestamp,
String nonce) {
// 对请求进行合法性验证
if (!WxUtils.checkSignature(signature, timestamp, nonce)) {
throw new IllegalArgumentException("非法请求,可能属于伪造的请求!");
}

// 业务逻辑
// 返回消息
}
}

接受到了微信服务器转发的消息,于是对这条消息解析,然后做一些业务逻辑,最后再返回消息。业务逻辑根据不同的场景有不同的实现,要做的工作是:

  • 消息解析
  • 封装返回的消息

微信服务器传过来的消息都是 XML 格式,公众号服务器传回去的消息也是 XML 格式。用户发送一条文字消息是,微信服务器会发过来一条这样的消息

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[asdfdsafasdfsadf]]></ToUserName>
<FromUserName><![CDATA[sadfasdfasdf]]></FromUserName>
<CreateTime>1548293882</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[我是消息内容]]></Content>
<MsgId>6649213423423412343</MsgId>
</xml>

可以以此写一个 Jave Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TextMessage {

private String ToUserName;
private String FromUserName;
private long CreateTime;
private String MsgType;
private String Content;
private long MsgId;

// getter

// setter
}

借助 dom4j 实现消息解析

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
public class MessageUtil {

/**
* 解析微信传过来的消息,封装在 Map 中
*/
public static Map<String, String> parseXml(String xmlStr) {
// 将解析结果存储在HashMap中
Map<String, String> result = new HashMap<>();

Document document;
try {
// 读取XML字符串
document = DocumentHelper.parseText(xmlStr);
} catch (DocumentException e) {
return null;
}
// 获取根节点
Element bookstore = document.getRootElement();
// 获取子节点的迭代器
Iterator iterator = bookstore.elementIterator();
// 遍历迭代器
while (iterator.hasNext()) {
Element element = (Element) iterator.next();
result.put(element.getName(), element.getText());
}
return result;
}
}

返回的消息,微信也有格式要求,比如

1
2
3
4
5
6
7
8
<xml>
<ToUserName><![CDATA[afdsadfsadf]]></ToUserName>
<FromUserName><![CDATA[adsfadsf]]></FromUserName>
<CreateTime><![CDATA[1548295128755]]></CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[接收到的消息是:看看咯啦咯]]></Content>
<MsgId><![CDATA[123]]></MsgId>
</xml>

借助 xtream 实现一个消息封装

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
public class MessageUtil {

private static final String CDATA_PREFIX = "<![CDATA[";
private static final String CDATA_SUFFIX = "]]>";

private static XStream getXStream() {
return new XStream(new XppDriver() {
@Override
public HierarchicalStreamWriter createWriter(Writer out) {
return new PrettyPrintWriter(out) {
@Override
protected void writeText(QuickWriter writer, String text) {
writer.write(CDATA_PREFIX);
writer.write(text);
writer.write(CDATA_SUFFIX);
}
};
}
});
}

/**
* Java Bean 转换成 XML 字符串
*/
public static String messageToXml(TextMessage textMessage) {
XStream xStream = getXStream();
xStream.alias("xml", textMessage.getClass());
return xStream.toXML(textMessage);
}
}

现在就可以写 Controller 层的代码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@PostMapping(produces = "application/xml;charset=UTF-8")
@ResponseBody
public String data(@RequestBody String requestBody,
String signature,
String timestamp,
String nonce) {
if (!WxUtils.checkSignature(signature, timestamp, nonce)) {
throw new IllegalArgumentException("非法请求,可能属于伪造的请求!");
}

Map<String, String> map = MessageUtil.parseXml(requestBody);

TextMessage textMessage = new TextMessage();
textMessage.setFromUserName(map.get("ToUserName"));
textMessage.setToUserName(map.get("FromUserName"));
textMessage.setCreateTime(System.currentTimeMillis());
textMessage.setMsgType("text");
textMessage.setContent("接收到的消息是:" + map.get("Content"));
textMessage.setMsgId(123456);

return MessageUtil.messageToXml(textMessage);
}

要注意的是 ToUserNameFromUserName 接收和发送时,两者是相反的

接收事件推送

用户和公众号交互时,会产生一些事件,比如用户关注了这公众号,微信服务器就会发送一个消息到我们的服务器;用户对公众号取消关注,微信服务器也会发送一个消息到我们的服务器

目前会推送的事件有:

  • 关注/取消关注事件
  • 扫描带参数二维码事件
  • 上报地理位置事件
  • 自定义菜单事件
  • 点击菜单拉取消息时的事件推送
  • 点击菜单跳转链接时的事件推送

可以看这篇文档微信公众平台技术文档-消息管理-接收事件推送

获取access_token

公众号服务器要想主动调用微信的服务器的话,先要获取到 access_token (接口访问凭证)才能调用。因为每时每刻都有请求在调用微信服务器,微信服务器通过 access_token 才能判断这个请求是哪一个公众号发来的请求。同时为了安全,access_token 有时间限制,一般是 7200 秒,超过了这个时间,access_token 就失效了

可以看这篇文档微信公众平台技术文档-开始开发-获取access_token

可以利用一个定时任务来获取 access_token,然后把获取到的 access_token 保存到内存或者数据库中

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
@Component
public class AccessTokenTask {

private static Logger log = LoggerFactory.getLogger(AccessTokenTask.class);

// 第一次延迟 1 秒执行,之后每隔 7000 秒执行
@Scheduled(initialDelay = 1000, fixedDelay = 7000 * 1000)
public void getAccessToken() {
OkHttpClient client = new OkHttpClient();

HttpUrl.Builder urlBuilder = HttpUrl
.get("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential")
.newBuilder()
.addQueryParameter("appid", WxConfig.APP_ID)
.addQueryParameter("secret", WxConfig.SECRET);

Request request = new Request.Builder().url(urlBuilder.build()).build();

try {
Response response = client.newCall(request).execute();
String jsonStr = response.body().string();
// 解析 json,获取 access_token
String accessToken = JSONUtil.getValue(jsonStr, "access_token");
if (StringUtils.isNotEmpty(accessToken)) {
// 保存 access_token
} else {
log.error("access_token获取失败,微信服务器返回:{}", jsonStr);
}
} catch (IOException e) {
log.error("access_token获取失败", e);
}
}
}

不要忘了在启动类加上 @EnableScheduling 注解开启定时任务

自定义菜单

文档看这里:微信公众平台技术文档-自定义菜单

个数限制

菜单最多 2 级。一级菜单最多 3 个,二级菜单最多 5 个

类型

目前支持 3 种类型的菜单:

  1. view:网页类型
  2. click:点击类型
  3. miniprogram:小程序类型

用户点击 view 类型的菜单,会调用微信内置的浏览器打开指定的网页。点击 click 类型的菜单,微信会把菜单的 key 值传给开发者服务器,开发者根据 key 值做操作并返回数据。点击 miniprogram 类型的菜单,会打开指定的小程序页面

封装菜单Java Bean