微信公众号开发
1、公众号机器人:包括设置菜单、自动回复、推送消息
2、公众号网页:即在网页中调用微信的JS-SDK;网页必须从属于公众号(服务器端给前端提供授权信息),才能得到调用JS-SDK的授权
公众号机器人 和 公众号网页 都要用到的 access_token 管理
用Bean(全局可用的单例)来管理access_token
注意:在不同机子上获取 access_token,会使得其他机子上的access_token失效
1、service
@Service
public class WeixinService {
@Value("${wx.appId}")
private String appId;
@Value("${wx.appSecret}")
private String appSecret;
@Autowired
private RestTemplate restTemplate;
private String accessToken;
private String jsApiTicket; // 只在 公众号网页用到
private Date tokenExpire;
private Date ticketExpire;
public String fetchToken() {
String urlStr = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appId, appSecret);
WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
accessToken = wxSession.getAccess_token();
long millis = System.currentTimeMillis() + (wxSession.getExpires_in() - 300) * 1000;
tokenExpire = new Date(millis);
return accessToken;
}
public String fetchTicket() {
String accessToken = getAccessToken();
String urlStr = String.format("https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=%s&type=jsapi", accessToken);
WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
jsApiTicket = wxSession.getTicket();
long millis = System.currentTimeMillis() + (wxSession.getExpires_in() - 300) * 1000;
ticketExpire = new Date(millis);
return jsApiTicket;
}
public String getAccessToken() {
if (accessToken == null || tokenExpire == null || tokenExpire.before(new Date())) {
fetchToken();
}
return accessToken;
}
public String getJsApiTicket() {
if (ticketExpire == null || ticketExpire.before(new Date())) {
fetchTicket();
}
return jsApiTicket;
}
}
2、用于微信相关请求 和 响应 的POJO
@Autowired
private WeixinService weixinService;
public class WxSession implements Serializable {
private String access_token; // 从微信获取 access_token 的响应
private String ticket; // 从微信获取 jsapi_ticket 的响应
private String url; // 前端请求 JS-SDK 签名 的参数
private long timestamp; // 前端获取 JS-SDK 签名的响应
private String nonceStr; // 前端获取 JS-SDK 签名的响应
private String signature; // 前端获取 JS-SDK 签名的响应
private String appId; // 前端获取 JS-SDK 签名的响应
private int expires_in;
private int errcode = 0;
private String errmsg;
}
3、由于 只有最后一次获取的access_token 有效,因此将access_token放到一个redis里;同时增加token无效时重试一次的机制
@Service
public class WeixinService {
@Value("${wx.appId}")
private String appId;
@Value("${wx.appSecret}")
private String appSecret;
@Value("${wx.tokenUrl}")
private String tokenUrl;
@Value("${wx.subscribeUrl}")
private String subscribeUrl;
@Autowired
private RestTemplate restTemplate;
@Resource
private StringRedisTemplate stringRedisTemplate;
private String accessToken;
private Date tokenExpire;
public String fetchToken() {
String urlStr = String.format(tokenUrl, appId, appSecret);
WxSession wxSession = restTemplate.getForObject(urlStr, WxSession.class);
accessToken = wxSession.getAccess_token();
long duration = wxSession.getExpires_in() - 300;
tokenExpire = new Date(System.currentTimeMillis() + duration * 1000);
stringRedisTemplate.opsForValue().set("access_token", accessToken);
stringRedisTemplate.expire("access_token", duration, TimeUnit.SECONDS);
return accessToken;
}
public String getAccessToken() {
if (accessToken == null) {
accessToken = stringRedisTemplate.opsForValue().get("access_token");
if (accessToken != null) {
Long millis = stringRedisTemplate.getExpire("access_token", TimeUnit.MILLISECONDS);
if(millis != null){
tokenExpire = new Date(System.currentTimeMillis() + millis);
}
}
}
if (accessToken == null || tokenExpire == null || tokenExpire.before(new Date())) {
fetchToken();
}
return accessToken;
}
// 推送订阅时,如果access_token无效,再次获取token,并重新推送一次
public void subscribePost(Object request){
String accessToken = this.getAccessToken();
String urlStr = String.format(subscribeUrl, accessToken);
WxSession wxSession = restTemplate.postForObject(urlStr, request, WxSession.class);
if (wxSession.getErrcode() != 0) {
if(wxSession.getErrcode() == 42001 || wxSession.getErrcode() == 40001){
accessToken = this.fetchToken();
urlStr = String.format(subscribeUrl, accessToken);
restTemplate.postForObject(urlStr, request, WxSession.class);
}
}
}
}
公众号网页
限制:
1、【设置->公众号设置->功能设置】JS接口安全域名 只能是已备案的域名,可以是http,端口必须是80或443
2、jsapi_ticket有效期两个小时,每次获取都不一样,只有最新的一次有效
获取JS-SDK签名
@Value("${wx.appId}")
private String appId;
@Autowired
private WeixinService weixinService;
@RequestMapping(value = "/signature", method = RequestMethod.POST)
public WxSession signature(@RequestBody WxSession wxSession) {
String jsApiTicket = weixinService.getJsApiTicket();
String nonceStr = RandomStringUtils.randomAlphanumeric(16); // 随机串
long timestamp = System.currentTimeMillis() / 1000; // 秒级时间戳
String url = wxSession.getUrl(); // 前端传来url
/* 字段名 字典排序,拼接 */
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("jsapi_ticket=" + jsApiTicket);
stringBuffer.append("&noncestr=" + nonceStr);
stringBuffer.append("×tamp=" + timestamp);
stringBuffer.append("&url=" + url);
String signature = DigestUtils.sha1Hex(stringBuffer.toString()); // sha1 摘要
return new WxSession(timestamp, nonceStr, signature, appId);
}
公众号机器人
限制:
1、被动回复用户消息:要求在5秒内必须回复
2、客服消息: 可以在用户操作后48小时内回复
3、回复图片或视频:回复多媒体消息只能是已经上传到【微信公众平台】的素材
4、access_token有效期两个小时,每次获取都不一样,只有最新的一次有效
5、媒体文件(临时素材)在微信后台保存时间为3天,即3天后media_id失效
6、永久素材库保存总数量有上限:图文消息素材、图片素材上限为100000,其他类型为1000
7、用户能发送的媒体格式 mp4、jpg、jpeg、png
8、不管用户发的图片是jpg还是png,微信发给我方服务器的 PicUrl 的 Content-Type 都是 image/jpeg
9、【开发->基本设置->服务器配置】服务器地址(URL)可以是IP,可以是http,端口必须是80或443
服务器配置
1、验证服务器(微信会发来多次请求,每次都响应正确即可验证通过)
@RequestMapping(value = "", method = RequestMethod.GET)
public String verify(@RequestParam("signature") String signature, @RequestParam("timestamp") String timestamp, @RequestParam("nonce") String nonce, @RequestParam("echostr") String echostr) {
List<String> list = new ArrayList<String>();
list.add(nonce);
list.add(timestamp);
list.add("token");
Collections.sort(list); // 将nonce、timestamp、token进行字典排序
// 连接后进行 sha1 摘要
String computSignature = DigestUtils.sha1Hex(list.get(0) + list.get(1) + list.get(2));
// 对比自己计算的signature 和 微信传来的signature
if (computSignature.equals(signature)) {
// 对比一致则返回 echostr 源串
return echostr;
}
return "验证错误!";
}
开启 服务器配置
1、【微信公众平台】开启 服务器配置 ,会导致【微信公众平台】上设置的 自定义菜单和自动回复失效
注:服务器配置开启后,自定义菜单需要调用微信的接口进行设置,调用微信接口都需要传access_token;
用户发送的消息会以POST的方式发送到配置的服务器URL
2、【微信公众平台】获取AppSecret
3、【微信公众平台】设置获取access_token 的 IP白名单
4、【微信公众平台接口调试工具】发送"获取Access Token"请求,得到access_token
注:access_token有效期两个小时
设置自定义菜单
自定义菜单的功能有:访问页面模板、访问已群发过的文章、访问小程序、发送素材
1、获取现有自定义菜单:【微信公众平台接口调试工具】发送"自定义菜单查询"请求
2、获取"页面模板"的URL:【微信公众平台】上复制
3、获取历史文章的URL:【微信公众平台】首页,找到历史群发文章,得到文章链接,去掉URL中尾部几个参数
4、获取素材的media_id:【Postman】发送 "获取素材列表" 请求
5、更新自定义菜单:【微信公众平台接口调试工具】发送"自定义菜单创建"请求
案例:用户发送视频,立马回复文字,再异步回复客服消息
用户发送的视频,视作一个临时素材;临时视频素材最大10MB,支持MP4格式
微信发来的数据是xml格式,返回给微信的数据也要是xml格式
1、pom.xml
<dependency>
<groupId>dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>1.6.1</version>
</dependency>
<dependency>
<groupId>jaxen</groupId>
<artifactId>jaxen</artifactId>
<version>1.1.6</version>
</dependency>
2、XML解析、生成 工具类
public class XMLUtil {
public static WxMsg parseXml(String xml) throws UnsupportedEncodingException,DocumentException {
logger.info(xml);
SAXReader reader = new SAXReader();
Document document = reader.read(new ByteArrayInputStream(xml.getBytes("UTF-8")));
Element root = document.getRootElement();
String fromUserName = root.selectSingleNode("/xml/FromUserName").getText();
String toUserName = root.selectSingleNode("/xml/ToUserName").getText();
String msgType = root.selectSingleNode("/xml/MsgType").getText();
WxMsg wxMsg = new WxMsg(fromUserName, toUserName, msgType);
if (msgType.equals("video")) {
String mediaId = root.selectSingleNode("/xml/MediaId").getText();
wxMsg.setMediaId(mediaId);
}
if (msgType.equals("image")) {
String picUrl = root.selectSingleNode("/xml/PicUrl").getText();
wxMsg.setPicUrl(picUrl);
}
// 订阅、点击菜单 等事件
if (msgType.equals("event")) {
String event = root.selectSingleNode("/xml/Event").getText();
wxMsg.setEvent(event);
// 点击菜单事件
if (event.equals("CLICK")) {
String eventKey = root.selectSingleNode("/xml/EventKey").getText();
wxMsg.setEventKey(eventKey);
}
}
return wxMsg;
}
public static String generateXml(WxMsg wxMsg) {
Document document = DocumentHelper.createDocument();
Element root = document.getRootElement();
root = document.addElement("xml");
root.addElement("FromUserName").addText(wxMsg.getFromUserName());
root.addElement("ToUserName").addText(wxMsg.getToUserName());
root.addElement("CreateTime").addText("" + System.currentTimeMillis());
root.addElement("MsgType").addText(wxMsg.getMsgType());
root.addElement("Content").addText(wxMsg.getContent());
return document.asXML();
}
}
3、Controller
@RequestMapping(value = "", method = RequestMethod.POST)
public String autoReply(HttpServletRequest request) throws Exception {
// 微信发来的数据是xml格式
String xml = new String(ByteStreams.toByteArray(request.getInputStream()));
// 解析XML
WxMsg receiveMsg = XMLUtil.parseXml(xml);
String msgType = receiveMsg.getMsgType();
String openId = receiveMsg.getFromUserName();
WxMsg responseMsg = new WxMsg(receiveMsg.getToUserName(), openId, "text");
// 区分动作类型
if (msgType.equals("event") && receiveMsg.getEvent().equals("subscribe")) { // 订阅事件
responseMsg.setContent("欢迎订阅");
return XMLUtil.generateXml(responseMsg);
}
// 点击菜单
if (msgType.equals("event") && receiveMsg.getEvent().equals("CLICK") && receiveMsg.getEventKey().equals("XX")) {
responseMsg.setContent("您点击了XX菜单");
return XMLUtil.generateXml(responseMsg);
}
if (!msgType.equals("video")) {
return "";
}
String mediaId = receiveMsg.getMediaId();
/* 相关业务逻辑,例如记录数据到数据库 */
// 根据mediaId,异步 从微信下载视频
asyncTasks.downloadFromWx(openId, mediaId);
// 给微信返回的也是 xml 格式
responseMsg.setContent("立即回复给用户的文字");
return XMLUtil.generateXml(responseMsg);
}
4、用于接收微信消息 和 回复微信消息的 POJO
public class WxMsg implements Serializable {
private String FromUserName;
private String ToUserName;
private String MsgType;
private String mediaId;
private String PicUrl;
private String Event;
private String EventKey;
private String Content;
private String CreateTime;
}
5、异步任务 或 定时任务
@Component
@EnableAsync
public class AsyncTasks {
@Autowired
private RestTemplate restTemplate;
@Async
public void downloadFromWx(String openId, String mediaId) throws Exception {
String appId = "***";
String appSecret = "***";
// 下载视频
String urlStr = String.format("http://api.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s", wexinService.getAccessToken(), mediaId);
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection)url.openConnection();
conn.setConnectTimeout(100*1000);
InputStream inputStream = conn.getInputStream();
/* 相关业务逻辑,例如记录数据到数据库 */
}
}
6、发送客服消息
// 构造url
String url = String.format("https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=%s", wexinService.getAccessToken());
// 构造请求
String content = "这是客服消息";
restTemplate.postForObject( url, new ServiceMsg(openId,"text",content), String.class );
7、客服消息请求体 POJO
public class ServiceMsg implements Serializable {
private String touser;
private String msgtype;
private Text text;
public class Text implements Serializable {
private String content;
}
}
案例:用户发送文字,自动回复一条视频
1、接收用户消息,记下openId,并立即(5秒内)返回一个 提示消息
2、异步任务调用 "新增临时素材",得到media_id
3、(48小时内)调用"客服接口-发消息",带上openId 和 media_id