最近在看微信公众号的开发文档,觉得很有意思,可以自定义开发一些功能,比如有人关注了公众号之后,你可以做出稍微复杂点的回复(简单的回复在公众号后台配置就好啦);比如关注者发送了「学习」消息,你可以给他推送一些文章,发送「天气」的消息,你可以回复当前的天气状况;还可以进行素材的管理,用户的管理等等。
今天先来实现下最简单的获取关注者发送的消息,并给他回复同样的消息,支持文本消息,图片和语音。后续再解锁其他的姿势。
先来看看最终效果:
接下来开始操作,主要分为以下几个步骤:
-
申请测试公众号
-
配置服务器地址
-
验证服务器地址的有效性
-
接收消息,回复消息
申请测试公众号
第一步首先要申请一个测试的微信公众号,地址:
https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login
配置服务器地址
在测试号页面上有接口配置信息选项,在这个选项下面进行配置:
有两个配置项:服务器地址(URL),Token,在正式的公众号上还有一个选项EncodingAESKey
-
URL:是开发者用来接收微信消息和事件的接口URL。
-
Token可可以任意填写,用作生成签名(该Token会和接口URL中包含的Token进行比对,从而验证安全性)。
-
EncodingAESKey由开发者手动填写或随机生成,将用作消息体加解密密钥。
当输入这个 URL 和 Token 点击保存的时候,需要后台启动并且验证 Token 通过之后才能保存,不然会保存失败,所以先把后台代码启动起来。
验证 Token
当填写 URL, Token,点击保存时,微信会通过 GET 的方式把微信加密签名(signature)
,时间戳(timestamp)
,随机数(nonce)
和随机字符串(echostr)
传到后台,我们根据这四个参数来验证 Token。
验证步骤如下:
-
将token、timestamp、nonce三个参数进行字典序排序。
-
将三个参数字符串拼接成一个字符串进行sha1加密.
-
获得加密后的字符串与signature对比,如果相等,返回echostr,表示配置成功,否则返回null,配置失败。
代码如下
首先在 application.properties
配置文件中配置项目启动端口,Token,appId和appsecret,其中,Token随便写,只要和页面配置一致即可,appId和appsecret 在测试公众号页面可以看到,如下所示:
server.port=80
wechat.interface.config.token=wechattoken
wechat.appid=wxfe39186d102xxxxx
wechat.appsecret=cac2d5d68f0270ea37bd6110b26xxxx
然后把这些配置信息映射到类属性上去:
@Component
public class WechatUtils {
@Value("${wechat.interface.config.token}")
private String token;
@Value("${wechat.appid}")
private String appid;
@Value("${wechat.appsecret}")
private String appsecret;
public String getToken() { return token; }
public String getAppid() {return appid;}
public String getAppsecret() {return appsecret;}
}
然后,需要有个方法,用来接收微信传过来的四个参数,请求的 URL 和页面配置的需要一样,如下:
@RestController
public class WechatController {
/** 日志 */
private Logger logger = LoggerFactory.getLogger(getClass());
/** 工具类 */
@Autowired
private WechatUtils wechatUtils;
/**
* 微信公众号接口配置验证
* @return
*/
@RequestMapping(value = "/wechat", method = RequestMethod.GET)
public String checkSignature(String signature, String timestamp,
String nonce, String echostr) {
logger.info("signature = {}", signature);
logger.info("timestamp = {}", timestamp);
logger.info("nonce = {}", nonce);
logger.info("echostr = {}", echostr);
// 第一步:自然排序
String[] tmp = {wechatUtils.getToken(), timestamp, nonce};
Arrays.sort(tmp);
// 第二步:sha1 加密
String sourceStr = StringUtils.join(tmp);
String localSignature = DigestUtils.sha1Hex(sourceStr);
// 第三步:验证签名
if (signature.equals(localSignature)) {
return echostr;
}
return null;
}
}
这里需要注意的是请求方式一定是 GET
的方式,POST
方式是用户每次向公众号发送消息、或者产生自定义菜单、或产生微信支付订单等情况时,微信还是会根据这个URL来发送消息或事件。
启动项目,这时在测试公众号配置 URL 和 Token:
你会发现保存失败,后台也没有接收到消息,日志都没有打印;这是因为是在本地启动的项目,访问地址为127.0.0.1,而在点击保存的时候,是由腾讯服务器发送过来的,人家的服务器自然是访问不到你本地啦,所以还需要一款内网穿透工具,把我们本机地址映射到互联网上去,这样腾讯服务器就可以把消息发送过来啦。
我使用的是 natapp
这款工具,运行之后,如下:
http://pquigs.natappfree.cc
这个互联网地址会映射到我们的本机127.0.0.1的地址,页面上就配置这个地址:
此时可以保存成功,日志打印如下:
到这里,我们就算配置服务器成功了,接下来就可以调用公众号接口开发了。
access_token
什么是 access_token
? 官网的介绍如下:
access_token
是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token
。开发者需要进行妥善保存。access_token
的存储至少要保留512个字符空间。access_token
的有效期目前为2
个小时,需定时刷新,重复获取将导致上次获取的access_token失效。
获取 access_token
的接口每日调用是有限制的,所以不是每次调用接口都重新获取access_token
,而是获取到之后缓存起来,缓存失效之后再去重新获取即刷新。
❝官方文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
❞
由于调用公众号开发接口基本都是XML格式的报文,所以为了开发简单,我们可以使用开源工具 weixin-java-mp
开发,pom.xml 文件添加相关依赖如下:
<dependency>
<groupId>me.chanjar</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>1.3.3</version>
</dependency>
<dependency>
<groupId>me.chanjar</groupId>
<artifactId>weixin-java-common</artifactId>
<version>1.3.3</version>
</dependency>
在 weixin-java-mp
中有两个重要的类,一个是配置相关的WxMpInMemoryConfigStorage
,一个是调用微信接口相关的WxMpServiceImpl
.
获取access_token
现在来获取下access_token:
-
接口地址为:
https请求方式: https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
-
参数说明
参数 | 是否必须 | 说明 |
---|---|---|
grant_type | 是 | 获取access_token填写client_credential |
appid | 是 | 第三方用户唯一凭证 |
secret | 是 | 第三方用户唯一凭证密钥,即appsecret |
-
返回的是JSON格式的报文:
{"access_token":"ACCESS_TOKEN","expires_in":7200}
代码如下
首先在文章开头的工具类 WechatUtils
中进行初始化 WxMpInMemoryConfigStorage
和 WxMpServiceImpl
,然后调用 WxMpServiceImpl 的 getAccessToken()
方法来获取 access_token:
/**
* 微信工具类
*/
@Component
public class WechatUtils {
@Value("${wechat.interface.config.token}")
private String token;
@Value("${wechat.appid}")
private String appid;
@Value("${wechat.appsecret}")
private String appsecret;
public String getToken() {return token;}
public String getAppid() {return appid;}
public String getAppsecret() {return appsecret;}
/**
* 调用微信接口
*/
private WxMpService wxMpService;
/**
* 初始化
*/
@PostConstruct
private void init() {
WxMpInMemoryConfigStorage wxMpConfigStorage = new WxMpInMemoryConfigStorage();
wxMpConfigStorage.setAppId(appid);
wxMpConfigStorage.setSecret(appsecret);
wxMpService = new WxMpServiceImpl();
wxMpService.setWxMpConfigStorage(wxMpConfigStorage);
}
/**
* 获取 access_token 不刷新
* @return access_token
* @throws WxErrorException
*/
public String getAccessToken() throws WxErrorException {
return wxMpService.getAccessToken();
}
在 WxMpServiceImpl 的 getAccessToken 方法中,会自动的在url中拼接 appId 和 appsecret,然后发送请求获取access_token,源码如下:
public String getAccessToken(boolean forceRefresh) throws WxErrorException {
.....
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential"
+ "&appid=" + wxMpConfigStorage.getAppId()
+ "&secret=" + wxMpConfigStorage.getSecret();
HttpGet httpGet = new HttpGet(url);
if (httpProxy != null) {
RequestConfig config = RequestConfig.custom().setProxy(httpProxy).build();
httpGet.setConfig(config);
}
CloseableHttpResponse response = getHttpclient().execute(httpGet);
String resultContent = new BasicResponseHandler().handleResponse(response);
WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
.....
}
启动项目,浏览器输入http://localhost/getAccessToken
,测试如下:
@RestController
public class WechatController {
/** 日志 */
private Logger logger = LoggerFactory.getLogger(getClass());
/** 工具类 */
@Autowired
private WechatUtils wechatUtils;
@RequestMapping("/getAccessToken")
public void getAccessToken() {
try {
String accessToken = wechatUtils.getAccessToken();
logger.info("access_token = {}", accessToken);
} catch (WxErrorException e) {
logger.error("获取access_token失败。", e);
}
}
}
除此之外,还可以获取关注者的列表,关注者的信息等。
获取用户的信息:
@RestController
public class WechatController {
/** 日志 */
private Logger logger = LoggerFactory.getLogger(getClass());
/** 工具类 */
@Autowired
private WechatUtils wechatUtils;
@RequestMapping("getUserInfo")
public void getUserInfo() {
try {
WxMpUserList userList = wechatUtils.getUserList();
if (userList == null || userList.getOpenIds().isEmpty()) {
logger.warn("关注者openId列表为空");
return;
}
List<String> openIds = userList.getOpenIds();
logger.info("关注者openId列表 = {}", openIds.toString());
String openId = openIds.get(0);
logger.info("开始获取 {} 的基本信息", openId);
WxMpUser userInfo = wechatUtils.getUserInfo(openId);
if (userInfo == null) {
logger.warn("获取 {} 的基本信息为空", openId);
return;
}
String city = userInfo.getCity();
String nickname = userInfo.getNickname();
logger.info("{} 的昵称为:{}, 城市为:{}", openId, nickname, city);
} catch (WxErrorException e) {
logger.error("获取用户消息失败", e);
}
}
}
接收用户发送的消息
当微信用户向公众号发送消息时,微信服务器会通过公众号后台配置的URL把信息发送到我们后台的接口上,注意此时的请求格式为 POST
请求,发送过来的消息报文格式是XML格式的,每种消息类型的XML格式不一样。
文本消息
当用户发送的是文本消息,接收到报文格式如下:
<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格式的,所以我们要解析,但是可以使用javax.xml.bind.annotation
对应的注解直接映射到实体类的属性上,现在创建一个实体类:
/**
* 接收消息实体
*/
@XmlRootElement(name = "xml")
@XmlAccessorType(XmlAccessType.FIELD)
public class ReceiveMsgBody {
/**开发者微信号*/
private String ToUserName;
/** 发送消息用户的openId */
private String FromUserName;
/** 消息创建时间 */
private long CreateTime;
/**消息类型*/
private String MsgType;
/** 消息ID,根据该字段来判重处理 */
private long MsgId;
/** 文本消息的消息体 */
private String Content;
// setter/getter
}
接收消息的方法如下,参数为 ReceiveMsgBody
:
@RestController
public class WechatController {
/** 日志 */
private Logger logger = LoggerFactory.getLogger(getClass());
/** 工具类 */
@Autowired
private WechatUtils wechatUtils;
/**
* 微信公众号接口配置验证
* @return
*/
@RequestMapping(value = "/wechat", method = RequestMethod.GET)
public String checkSignature(String signature, String timestamp, String nonce, String echostr) {
// 第一步:自然排序
String[] tmp = {wechatUtils.getToken(), timestamp, nonce};
Arrays.sort(tmp);
// 第二步:sha1 加密
String sourceStr = StringUtils.join(tmp);
String localSignature = DigestUtils.sha1Hex(sourceStr);
// 第三步:验证签名
if (signature.equals(localSignature)) {
return echostr;
}
return null;
}
/**
* 接收用户消息
* @param receiveMsgBody 消息
* @return
*/
@RequestMapping(value = "/wechat", method = RequestMethod.POST, produces = {"application/xml; charset=UTF-8"})
@ResponseBody
public Object getUserMessage(@RequestBody ReceiveMsgBody receiveMsgBody) {
logger.info("接收到的消息:{}", receiveMsgBody);
}
}
注意这里请求方式为 POST
,报文格式为 xml
。
启动项目,给测试号发送消息「哈哈」,接收到的消息如下:
图片消息和语音消息也是一样的获取。
图片消息
报文格式:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[image]]></MsgType>
<PicUrl><![CDATA[this is a url]]></PicUrl>
<MediaId><![CDATA[media_id]]></MediaId>
<MsgId>1234567890123456</MsgId>
</xml>
参数 | 描述 |
---|---|
MsgType | 消息类型,image |
PicUrl | 图片链接(由系统生成) |
MediaId | 图片消息媒体id,可以调用获取临时素材接口拉取数据 |
语音消息
报文格式:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1357290913</CreateTime>
<MsgType><![CDATA[voice]]></MsgType>
<MediaId><![CDATA[media_id]]></MediaId>
<Format><![CDATA[Format]]></Format>
<MsgId>1234567890123456</MsgId>
</xml>
参数 | 描述 |
---|---|
MsgType | 消息类型,voice |
Format | 语音格式,如amr,speex等 |
MediaId | 语音消息媒体id,可以调用获取临时素材接口拉取数据 |
回复用户消息
当用户发送消息给公众号时,会产生一个POST请求,开发者可以在响应包(Get)中返回特定XML结构,来对该消息进行响应(现支持回复文本、图片、图文、语音、视频、音乐)。
也就是说收到消息后,需要返回一个XML格式的报文回去,微信会解析这个报文,然后把消息给用户推送过去。
回复文本消息
需要返回的报文格式如下:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[你好]]></Content>
</xml>
参数 | 描述 |
---|---|
ToUserName | 用户的openId |
FromUserName | 开发者微信号 |
CreateTime | 消息创建时间 (整型) |
MsgType | 消息类型,文本为text |
Content | 文本消息内容 |
这是同样创建一个响应消息的实体类,用xml注解标注:
/**
* 响应消息体
*/
@XmlRootElement(name = "xml")
@XmlAccessorType(XmlAccessType.FIELD)
public class ResponseMsgBody {
/**接收方帐号(收到的OpenID)*/
private String ToUserName;
/** 开发者微信号 */
private String FromUserName;
/** 消息创建时间 */
private long CreateTime;
/** 消息类型*/
private String MsgType;
/** 文本消息的消息体 */
private String Content;
// setter/getter
}
响应消息:
/**
* 接收用户下消息
* @param receiveMsgBody 消息
* @return
*/
@RequestMapping(value = "/wechat", method = RequestMethod.POST, produces = {"application/xml; charset=UTF-8"})
@ResponseBody
public Object getUserMessage(@RequestBody ReceiveMsgBody receiveMsgBody) {
logger.info("接收到的消息:{}", receiveMsgBody);
// 响应的消息
ResponseMsgBody textMsg = new ResponseMsgBody();
textMsg.setToUserName(receiveMsgBody.getFromUserName());
textMsg.setFromUserName(receiveMsgBody.getToUserName());
textMsg.setCreateTime(new Date().getTime());
textMsg.setMsgType(receiveMsgBody.getMsgType());
textMsg.setContent(receiveMsgBody.getContent());
return textMsg;
}
这里的 ToUserName 和 FromUserName 和接收的消息中反过来即可,内容也原样返回。
这样,用户发送什么,就会收到什么。
回复图片消息
需要返回的报文格式如下:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[image]]></MsgType>
<Image>
<MediaId><![CDATA[media_id]]></MediaId>
</Image>
</xml>
需要注意的是 MediaId
标签外面还有一个标签Image
,如果直接映射为实体类的属性,则返回的消息用户是接收不到的,此时需要使用 XmlElementWrapper
注解来标识上级标签的名称,且只能作用在数组上:
/**
* 图片消息响应实体类
*/
@XmlRootElement(name = "xml")
@XmlAccessorType(XmlAccessType.FIELD)
public class ResponseImageMsg extends ResponseMsgBody {
/** 图片媒体ID */
@XmlElementWrapper(name = "Image")
private String[] MediaId;
public String[] getMediaId() {return MediaId;}
public void setMediaId(String[] mediaId) {MediaId = mediaId;}
}
设置响应消息:
ResponseVoiceMsg voiceMsg = new ResponseVoiceMsg();
voiceMsg.setToUserName(receiveMsgBody.getFromUserName());
voiceMsg.setFromUserName(receiveMsgBody.getToUserName());
voiceMsg.setCreateTime(new Date().getTime());
voiceMsg.setMsgType("voice");
voiceMsg.setMediaId(new String[]{receiveMsgBody.getMediaId()});
回复语音消息:
需要返回的报文格式如下:
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>12345678</CreateTime>
<MsgType><![CDATA[voice]]></MsgType>
<Voice>
<MediaId><![CDATA[media_id]]></MediaId>
</Voice>
</xml>
MediaId
标签的上层标签是Voice
,响应实体类为:
/**
* 语音消息响应实体类
*/
@XmlRootElement(name = "xml")
@XmlAccessorType(XmlAccessType.FIELD)
public class ResponseVoiceMsg extends ResponseMsgBody{
/** 图片媒体ID */
@XmlElementWrapper(name = "Voice")
private String[] MediaId;
public String[] getMediaId() {
return MediaId;
}
public void setMediaId(String[] mediaId) {MediaId = mediaId; }
}
设置响应消息:
ResponseVoiceMsg voiceMsg = new ResponseVoiceMsg();
voiceMsg.setToUserName(receiveMsgBody.getFromUserName());
voiceMsg.setFromUserName(receiveMsgBody.getToUserName());
voiceMsg.setCreateTime(new Date().getTime());
voiceMsg.setMsgType(MsgType.voice.getMsgType());
voiceMsg.setMediaId(new String[]{receiveMsgBody.getMediaId()});
到这里,接收文本消息,图片消息,语音消息,回复文本消息,图片消息,语音消息基本完毕了,接下来整合一下实现文章开头的效果。
整合
因为消息由很多类型,所以定义一个枚举类:
/**
* 消息类型枚举
*/
public enum MsgType {
text("text", "文本消息"),
image("image", "图片消息"),
voice("voice", "语音消息"),
video("video", "视频消息"),
shortvideo("shortvideo", "小视频消息"),
location("location", "地理位置消息"),
link("link", "链接消息"),
music("music", "音乐消息"),
news("news", "图文消息"),
;
MsgType(String msgType, String msgTypeDesc) {
this.msgType = msgType;
this.msgTypeDesc = msgTypeDesc;
}
private String msgType;
private String msgTypeDesc;
//setter/getter
/**
* 获取对应的消息类型
* @param msgType
* @return
*/
public static MsgType getMsgType(String msgType) {
switch (msgType) {
case "text":
return text;
case "image":
return image;
case "voice":
return voice;
case "video":
return video;
case "shortvideo":
return shortvideo;
case "location":
return location;
case "link":
return link;
case "music":
return music;
case "news":
return news;
default:
return null;
}
}
}
然后接收到消息之后,判断类型,根据类型不同,回复不同类型的消息就可以了:
@RestController
public class WechatController {
/** 日志 */
private Logger logger = LoggerFactory.getLogger(getClass());
/** 工具类 */
@Autowired
private WechatUtils wechatUtils;
/**
* 接收用户消息
* @param receiveMsgBody 消息
* @return
*/
@RequestMapping(value = "/wechat", method = RequestMethod.POST, produces = {"application/xml; charset=UTF-8"})
@ResponseBody
public Object getUserMessage(@RequestBody ReceiveMsgBody receiveMsgBody) {
logger.info("接收到的消息:{}", receiveMsgBody);
MsgType msgType = MsgType.getMsgType(receiveMsgBody.getMsgType());
switch (msgType) {
case text:
logger.info("接收到的消息类型为{}", MsgType.text.getMsgTypeDesc());
ResponseMsgBody textMsg = new ResponseMsgBody();
textMsg.setToUserName(receiveMsgBody.getFromUserName());
textMsg.setFromUserName(receiveMsgBody.getToUserName());
textMsg.setCreateTime(new Date().getTime());
textMsg.setMsgType(MsgType.text.getMsgType());
textMsg.setContent(receiveMsgBody.getContent());
return textMsg;
case image:
logger.info("接收到的消息类型为{}", MsgType.image.getMsgTypeDesc());
ResponseImageMsg imageMsg = new ResponseImageMsg();
imageMsg.setToUserName(receiveMsgBody.getFromUserName());
imageMsg.setFromUserName(receiveMsgBody.getToUserName());
imageMsg.setCreateTime(new Date().getTime());
imageMsg.setMsgType(MsgType.image.getMsgType());
imageMsg.setMediaId(new String[]{receiveMsgBody.getMediaId()});
return imageMsg;
case voice:
logger.info("接收到的消息类型为{}", MsgType.voice.getMsgTypeDesc());
ResponseVoiceMsg voiceMsg = new ResponseVoiceMsg();
voiceMsg.setToUserName(receiveMsgBody.getFromUserName());
voiceMsg.setFromUserName(receiveMsgBody.getToUserName());
voiceMsg.setCreateTime(new Date().getTime());
voiceMsg.setMsgType(MsgType.voice.getMsgType());
voiceMsg.setMediaId(new String[]{receiveMsgBody.getMediaId()});
return voiceMsg;
default:
// 其他类型
break;
}
return null;
}
}
这就是实现接收用户消息,回复用户消息的全部实现了,还是比较简单的;除了这个,还可以自定义菜单,关注/取消事件的监听处理,用户管理等其他操作,后面有时间了再慢慢研究吧。
❝代码已经上传到github上,有兴趣的试一下吧。 https://github.com/tsmyk0715/wechatproject
❞
往期精彩回顾
Ribbon 负载均衡器 LoadBalancer 源码解析