前几天在逛blog时,看到江南一点雨大佬的一篇文章提到了用springboot开发一个公众号。于是心血来潮准备尝试着也做一下。
先去微信平台开通一个个人订阅号并完成实名认证,这里不再赘述。
springboot开发订阅号首先要明白公众号信息先发送到微信服务器,然后再由微信服务器转发到自己定义的接口。
先上项目整体结构图:
接入指南:
这里需要填写自己微信公众号服务的url。这个token自己写,用来帮助微信服务器验证请求端口是否时上面填入的url。
开发者提交信息后,微信将会向所填的url发送一个GET请求来验证,此请求携带有三个参数:signature,timesstamp,nonce,echostr。
参数 | 描述 |
---|---|
signature | 微信加密签名,signature结合了开发者填写的 token 参数和请求中的 timestamp 参数、nonce参数。 |
timestamp | 时间戳 |
nonce | 随机数 |
echostr | 随机字符串 |
完成验证要将token、timestamp、nonce
三个参数进行字典序排序,然后把三个参数字符串拼接成一个字符串并进行sha1
加密 ,将获得加密后的字符串可与 signature
对比,对比一致然后返回echostr
即可完成“配对”。
Controller层:
@GetMapping("/wxx") public void login(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws UnsupportedEncodingException { httpServletRequest.setCharacterEncoding("UTF-8"); String signature=httpServletRequest.getParameter("signature"); String timestamp=httpServletRequest.getParameter("timestamp"); String nonce=httpServletRequest.getParameter("nonce"); String echostr=httpServletRequest.getParameter("echostr"); PrintWriter out=null; try{ out =httpServletResponse.getWriter(); if(CheckToken.checkSignture(signature,timestamp,nonce)){ out.write(echostr); } } catch (IOException e) { e.printStackTrace(); }finally { out.close(); } }
在util里面进行signature验证:
public class CheckToken { private static final String token="xxxxxx";//公众号配置里面自己定义的token public static boolean checkSignture(String signture, String timestamp, String nonce){ String[] str=new String[]{token,timestamp,nonce}; Arrays.sort(str); StringBuffer buffer=new StringBuffer(); for(int i=0;iSHA1加密方式demo:
(什么是SHA1加密:SHA是一种数据
加密算法,该算法经过加密专家多年来的发展和改进已日益完善,现在已成为公认的最安全的散列算法之一,并被广泛使用。该算法的思想是接收一段明文,然后以一种不可逆的方式将它转换成一段(通常更小)密文,也可以简单的理解为取一串输入码(称为预映射或信息),并把它们转化为长度较短、位数固定的输出序列即散列值(也称为信息摘要或信息认证代码)的过程。)
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>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); } } } 第一步验证对接成功后就可以实现业务逻辑。
基础信息能力:
当普通微信用户向公众账号发消息时,微信服务器将 POST 消息的 XML 数据包到开发者填写的 URL 上,并且服务端向微信服务器也要返回xml格式的数据。
EncodingAESKey
消息加密密钥可随机生成也可以自己填写,这里我为了方便将消息加密解密方式设置为了明文模式。消息分类
微信发送来的 xml 消息中有一个 MsgType 字段,这个字段就是用来标记消息的类型。这个类型可以标记出这条消息是普通消息还是事件消息还是图文消息等。
普通消息主要是指:
官方文档
文本消息
图片消息
语音消息
视频消息
小视频消息
地址位置消息
链接消息
不同的消息类型,对应不同的 MsgType,这里我还是以普通消息为例,如下:
消息类型 MsgType 文本消息 text 图片消息 image 语音消息 voice 视频消息 video 小视频消息 shortvideo 地址位置消息 location 链接消息 link 事件消息主要指:
官方文档
1 关注/取消关注事件
2 扫描带参数二维码事件
3 上报地理位置事件
4 自定义菜单事件
5 点击菜单拉取消息时的事件推送
6 点击菜单跳转链接时的事件推送
因为是个人订阅号,可用的事件只有关注和取消,其他事件类型接口对个人订阅号不开放,需要进行认证.
以订阅事件为例:
微信服务端发送的XML数据:
123456789 参数说明:
参数 描述 ToUserName 开发者微信号 FromUserName 发送方帐号(一个OpenID) CreateTime 消息创建时间 (整型) MsgType 消息类型,event Event 事件类型,subscribe(订阅)、unsubscribe(取消订阅) 这里以微信服务器发送的文本消息的XML为例:
官方文档
1348831860 1234567890123456 xxxx xxxx
参数 描述 ToUserName 开发者微信号 FromUserName 发送方帐号(一个OpenID) CreateTime 消息创建时间 (整型) MsgType 消息类型,文本为text Content 文本消息内容 MsgId 消息id,64位整型 MsgDataId 消息的数据ID(消息如果来自文章时才有) Idx 多图文时第几篇文章,从1开始(消息如果来自文章时才有) 知道所有的消息类型后创建一个util类来列出所有的msgtype,以便后面来判断或定义接受和返回消息的Msgtype
/** * 返回消息类型:文本 */ 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(上报地理位置事件) */这里还需要定义一个函数需要将微信服务端返回的xml中标签和text读取出来存入一个map中,然后进行后续的返回消息的逻辑判断.
public static MapparseXml(HttpServletRequest request) throws Exception { Map map = new HashMap (); InputStream inputStream = request.getInputStream(); SAXReader reader = new SAXReader(); Document document = reader.read(inputStream); Element root = document.getRootElement(); List elementList = root.elements(); for (Element e : elementList) { map.put(e.getName(), e.getText()); } inputStream.close(); inputStream = null; return map; } 在controller层调用函数将微信服务器发来的xml转成map,注意要设置
HttpServletRequest
编码方式为utf-8
,防止出现中文乱码.@PostMapping(value = "/wxx",produces = "application/xml;charset=utf-8") public String handler(HttpServletRequest request, HttpServletResponse response) throws Exception { request.setCharacterEncoding("UTF-8"); Mapmap = MsgTypeAndXml.parseXml(request); Map=map; 我们定义一个基础信息类
public class BaseMessage { private String ToUserName; private String FromUserName; private long CreateTime; private String MsgType; //省略get和set方法 }在这里:
ToUserName 接收者 FromUserName 发送者 CreateTime 创建时间 MsgType 消息类型 创建文本消息返回对象TextMessage并继承BaseMessage
public class TextMessage extends BaseMessage{ private String Content; public String getContent() { return Content; } public void setContent(String content) { Content = content; } }在这里:
Content表示发送的文本内容,这里要注意Content变量为首字母为大写,因为返回消息要求标签为大写.
在POST请求中获取map里面的MsgType的value来获取获取消息的类型,并判断消息类型是事件还是基本消息内容,然后执行不同的返回.
String msgType = map.get("MsgType"); if(MsgTypeAndXml.REQ_MESSAGE_TYPE_EVENT.equals(msgType)){ return MessageDispatcher.processEvent(map); } else { return MessageDispatcher.processMessage(map); }到此为止,已经完成呢对微信服务器返回xml数据转为map存储,所有的消息类型罗列,接收消息类型的判断.接下来去实现返回消息的自定义和xml格式数据类型的生产.
返回基本消息的自定义:
public static String processMessage(Mapmap) throws InterruptedException { if (Integer.parseInt(map.get("Content"))==0) { return getFunction.GuideSign(map); } else if (Integer.parseInt(map.get("Content"))==1) { return getRandomSay.RandomSay(map); }else if(Integer.parseInt(map.get("Content"))==2) { return getRandomLoLPhoto.RandomLoLPhoto(map); }else if(Integer.parseInt(map.get("Content"))==3) { return getRandomLolLine.RandomLoLLine(map); }else if (Integer.parseInt(map.get("Content"))==4){ return getLOLVideo.LoLVideo(map); }else { return getFunction.GuideSign(map); } } 在这里我以用户返回特定的数字来获取相应的功能.
当用户返回0:返回所有功能及其对应的数字;
当用户返回1:随机返回美句;
当用户返回2;随机返回一张照片;
当用户返回3:随机返回一句lol台词;
当用户返回4:返回RNG选手的生涯video;
..............
返回事件消息的自定义:
public static String processEvent(Mapmap) { Date data = new Date(); String openid = map.get("FromUserName"); String mpid = map.get("ToUserName"); TextMessage txtmsg = new TextMessage(); txtmsg.setToUserName(openid); txtmsg.setFromUserName(mpid); txtmsg.setCreateTime(data.getTime()); if(MsgTypeAndXml.EVENT_TYPE_SUBSCRIBE.equals(map.get("Event"))) { txtmsg.setMsgType(MsgTypeAndXml.RESP_MESSAGE_TYPE_TEXT); txtmsg.setContent("欢迎订阅CtrlCVer!了解更多功能请回复0!"); userService1.InsertUser(openid); return MsgTypeAndXml.textMessageToXml(txtmsg); } else if (MsgTypeAndXml.EVENT_TYPE_UNSUBSCRIBE.equals(map.get("Event"))) { userService1.DeleteUserByOpenId(openid); return null; }else { txtmsg.setMsgType(MsgTypeAndXml.RESP_MESSAGE_TYPE_TEXT); txtmsg.setContent("emmmmmmmmm"); return MsgTypeAndXml.textMessageToXml(txtmsg); } } 在这里我设置当用户订阅时将返回消息来引导新用户获取订阅号功能.
返回文本消息:
public class getRandomSay { public static String RandomSay(Mapmap){ Date data=new Date(); TextMessage txtmsg = new TextMessage(); txtmsg.setToUserName(map.get("FromUserName")); txtmsg.setFromUserName(map.get("ToUserName")); txtmsg.setCreateTime(data.getTime()); txtmsg.setMsgType(MsgTypeAndXml.RESP_MESSAGE_TYPE_TEXT); txtmsg.setContent(RandomSay.sendGet("https://v1.hitokoto.cn/?encode=text&c=a", "utf-8")); return MsgTypeAndXml.textMessageToXml(txtmsg); } } 这里新建一个Text对象,并根据微信服务端接收xml数据转化的map设置返回参数.
这里我自定义了一个方法RandomSay.sendGet,传入随机一言的接口和编码方式,返回String类型的一句话.
实现代码:
public static String sendGet(String url, String charset) { // String result = ""; BufferedReader in = null; try { URL realUrl = new URL(url); // 打开和URL之间的连接 URLConnection connection = realUrl.openConnection(); // 设置通用的请求属性 connection.setRequestProperty("accept", "*/*"); connection.setRequestProperty("connection", "Keep-Alive"); connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); // 建立实际的连接 connection.connect(); // 定义 BufferedReader输入流来读取URL的响应 in = new BufferedReader(new InputStreamReader( connection.getInputStream(), charset)); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception e) { System.out.println("发送GET请求出现异常!" + e); e.printStackTrace(); } // 使用finally块来关闭输入流 finally { try { if (in != null) { in.close(); } } catch (Exception e2) { e2.printStackTrace(); } } return result; }返回图片消息:
官方文档1
官方文档2
一定要阅读!!!!!!
图片xml数据消息返回模板:
12345678 图片消息和文本消息有所不同, 你需要先将你的图片按要求发送到微信服务器接口,然后就会返回一个MediaId, 这可以认为是一个对于微信内部的一个url,这个素材是临时的只会保留三天. 你只需要返回mediaid可以返回图片消息了.
新建ImageMessage对象和Image对象:
//Image对象 public class Image { private String MediaId; public Image(String mediaId) { MediaId = mediaId; } public String getMediaId() { return MediaId; } public void setMediaId(String mediaId) { MediaId = mediaId; } } //ImageMessage对象并继承BaseMessage public class ImageMessage extends BaseMessage { private Image Image; public Image getImage() { return Image; } public void setImage(Image image) { this.Image = image; } }在ImageMessage对象里面包含一个Image对象,这样在转化成XML数据时MediaId外面就会嵌套
标签. 在这里要注意ImageMessage对象里面定义的Image对象首字母要大写,因为返回的xml数据标签要求大写.
public static String RandomLoLPhoto(Mapmap) throws InterruptedException { Date data=new Date(); ImageMessage imageMessage = new ImageMessage(); imageMessage.setToUserName(map.get("FromUserName")); imageMessage.setFromUserName(map.get("ToUserName")); imageMessage.setCreateTime(data.getTime()); imageMessage.setMsgType(MsgTypeAndXml.RESP_MESSAGE_TYPE_Image); Thread threadOne = new Thread(new Runnable() { public void run() { System.out.println(userService1.findMediaId()); imageMessage.setImage(new Image(userService1.findMediaId())); } }); Thread threadTwo = new Thread(new Runnable() { public void run() { userService1.insertMediaId(UpImage.upload(userService1.findToken())); } }); threadOne.start(); threadTwo.start(); Thread.sleep(1000); return MsgTypeAndXml.imageMessageToXml(imageMessage); } 微信接口必须要五秒内进行响应,对于一般的文本返回信息还好.,但对于图片消息意味着图片上传获取MediaId和返回xml格式消息需要在五秒内响应.
对于一般服务器也可以. 但是我这里使用了我自己的图床接口, 先从我图床接口请求图片流, 然后再把图片流传到上传的微信api接口. 我本地运行测试还可以,但是放到我单核1M的服务器就超时了. 于是我改变思路, 建立一张表存储一个MediaId, 初始时放入一个MediaId, 并建立一个双线程, 一个来运行数据查询一个MediaId来插入返回消息,先去响应服务端, 另一个线程进行上传图片流获取mediaid,让它慢慢运行.
这里要设置一个合适的sleep来保证第一个线程已经运行,否则查询结果还没出来, 插入的MediaId是空的. 服务器是单核的本来运行多线程效果提升一般,但是我insertmediaid是一个io流操作,正好cpu给线程1用. 原来是即时获取mediaid,现在使用的是交错进行,即这次图片请求发送的是上次请求返回的mediaid。先返回信息给给服务端,然后把耗时的对到后端慢漫运行。
public class UpImage { public static String upload(String Token) { String urlList = "自己的图片接口"; Random r=new Random(); String result=null; String mediaId=null; try { URL imgurl=new URL(urlList); String token=Token; String urlString="https://api.weixin.qq.com/cgi-bin/media/upload? access_token=ACCESS_TOKEN&type=TYPE".replace("ACCESS_TOKEN", token).replace("TYPE", "image"); URL url=new URL(urlString); HttpsURLConnection conn=(HttpsURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setDoInput(true); conn.setDoOutput(true); conn.setUseCaches(false); //设置请求头信息 conn.setRequestProperty("Connection", "Keep-Alive"); conn.setRequestProperty("Charset", "UTF-8"); //设置边界 String BOUNDARY="----------"+System.currentTimeMillis(); conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDARY); //请求正文信息 //第一部分 StringBuilder sb=new StringBuilder(); sb.append("--"); sb.append(BOUNDARY); sb.append("\r\n"); sb.append("Content-Disposition: form-data;name=\"media\"; filename=\"" + r.nextInt(2000)+".jpg"+"\"\r\n"); sb.append("Content-Type:application/octet-stream\r\n\r\n"); //获得输出流 OutputStream out=new DataOutputStream(conn.getOutputStream()); //输出表头 out.write(sb.toString().getBytes("UTF-8")); //文件正文部分 //把文件以流的方式 推送道URL中 DataInputStream din=new DataInputStream(imgurl.openStream()); int bytes=0; byte[] buffer=new byte[1024]; while((bytes=din.read(buffer))!=-1){ out.write(buffer,0,bytes); } din.close(); //结尾部分 byte[] foot=("\r\n--" + BOUNDARY + "--\r\n").getBytes("UTF-8");//定义数据最后分割线 out.write(foot); out.flush(); out.close(); if(HttpsURLConnection.HTTP_OK==conn.getResponseCode()){ StringBuffer strbuffer=null; BufferedReader reader=null; try { strbuffer=new StringBuffer(); reader=new BufferedReader(new InputStreamReader(conn.getInputStream())); String lineString=null; while((lineString=reader.readLine())!=null){ strbuffer.append(lineString); } if(result==null){ result=strbuffer.toString(); JSONObject jsonObj = JSONObject.fromObject(result); String typeName = "media_id"; mediaId = jsonObj.getString(typeName); } } catch (IOException e) { System.out.println("发送POST请求出现异常!"+e); e.printStackTrace(); }finally { if (reader != null) { reader.close(); } } } } catch (IOException e) { e.printStackTrace(); } System.out.println(mediaId); return mediaId; } }注意这里:
sb.append("Content-Disposition: form-data;name=\"media\"; filename=\"" + r.nextInt(2000)+".jpg"+"\"\r\n");filename一定要以jpg结尾.
UpImage获取MediaId需要access_token验证,
获取acces_token方法:
官网文档
public static String sendGet() { String result=""; BufferedReader in = null; try { URL realUrl = new URL("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=wx5d86f3e043126ffe&secret=1869e6a670cd896f8417cdd880e27657"); // 打开和URL之间的连接 URLConnection connection = realUrl.openConnection(); // 设置通用的请求属性 connection.setRequestProperty("accept", "*/*"); connection.setRequestProperty("connection", "Keep-Alive"); connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); // 建立实际的连接 connection.connect(); // 定义 BufferedReader输入流来读取URL的响应 in = new BufferedReader(new InputStreamReader( connection.getInputStream(), "utf-8")); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception e) { System.out.println("发送GET请求出现异常!" + e); e.printStackTrace(); } // 使用finally块来关闭输入流 finally { try { if (in != null) { in.close(); } } catch (Exception e2) { e2.printStackTrace(); } } JSONObject jsonObject = JSONObject.parseObject(result); return jsonObject.getString("access_token"); }这里的json使用的阿里云的FastJson, 使用配置自行百度即可.
access_token有效时间为7200秒,过期需要重新申请
定时刷新Token:
这里提供我自己的方法:(其实最好用redis)
我是利用springboot设置定时功能实现的
@Component public class setTimer { @Autowired UserService userService; @Autowired LolVideoService lolVideoService; /** *@author CtrlCver *@data 2022/11/3 *@description: d=定时刷新数据库token */ @Scheduled(fixedRate = 7100000)//预留100s以防万一 public void updateToken(){ String token= AccessToken.sendGet(); userService.UpdateToken(token); }再启动类加入注解@EnableScheduling
@EnableScheduling @SpringBootApplication public class WxApplication { public static void main(String[] args) { SpringApplication.run(WxApplication.class, args); } }以上基本就是订阅号开发的基本流程, 描述可能有点乱, 思路就是这样. 其他类型的数据返回基本一致.
补充:
静态方法调用xxxService进行数据库操作方法:
@Component public class getRandomLolLine { @Autowired LolLinesMapper lolLinesMapper; @Autowired static LolLinesMapper lolLinesMapper1; @PostConstruct public void init(){ lolLinesMapper1=lolLinesMapper; } }数据库出现某个ip地址无法访问,没有权限:
执行这两个命令即可.