目录
- 前言
- 技术栈及工具
- 一、后台配置
- 应用回调配置
- 通用开发参数回调配置
- 二、构建spring-boot项目
- 新建项目
- 项目目录结构
- 导入加解密包
- 写验证类
- 1、回调验证
- 2、获取suite_ticket及auth_code
- 总结
前言
- 我们在新建好一个网页应用后,需要填一些基础配置。其中最重要的就是一些回调配置,回调路径指向我们自己的服务器,需要正确接收响应微信服务器的请求。
- 微信接口开发无非就是通过一些带token的http请求来实现相关功能,而获取这些token往往需要先获取一些重要参数,这里有两个参数需要先获取:suite_ticket 和 auth_code
- 对微信开发不熟,或者没读我之前文章的同学一定要搞清一个概念:在三方应用开发中,应用开发方统称三方服务商,对应id我会称 【服务商corpId】;应用使用方称作 企业,对应id叫 【企业corpId】。请不要搞混了
技术栈及工具
- 开发框架:spring-boot
- 开发工具: idea
一、后台配置
应用回调配置
首先我们进入新建的应用:
其中最主要的配置就是这个回调配置:
共有四个配置项,分别是:
- 两个回调URL
- Token(密钥,不可泄漏)
- EncodingAESKey(加密消息内容的码)
这里的URL就是我们服务器的响应路径,微信服务器会根据这里的URL向我们服务器发送请求,我们服务器验证方面需要做两件事:
- 分辨出是否为企业微信来源
- 分辨出推送消息的内容是否被篡改
填写好回调URL,并选择自动生成Token和EncodingAESKey
简单一句话概括就是,在某些时点(具体什么时点或访问哪个回调请看下面 “写VerifyController类” 内容),微信服务器会向我们服务器发送验证请求,我们服务器需要通过Token、EncodingAESKey和服务商CorpID计算Signature与GET请求参数中的Signature作对比,如果一样就验证通过,返回"success"。
通用开发参数回调配置
还有一个重要参数需要设置,就是 通用开发参数 里的 系统事件接收URL。
当 企业 安装应用时,微信服务器会向这个 系统事件接收URL 发送验证请求,我们需要做的响应跟上面的应用回调验证基本一样。 所以我将两者URL、Token、EncodingAESKey设为一样,通过同一个接口来处理这两种请求。
由于后台还未搭建,这些设置都保存不了,接下来我们先搭建后台。
二、构建spring-boot项目
Spring Boot的出现简直就是像我这样的后端渣渣的福音,我们可以很快速的构建一个服务系统。所以我很愉快的选择了它!
还有很重要的一点,本系列文重在打通企业微信的一些功能,以及验证一些逻辑。代码尽量简洁。所以我暂时不会遵循实战项目的设计逻辑,也缺少一些排错处理。请读者知悉!
新建项目
新建一个项目:cwp(company wechat project之意)
并新建一些必要的包和类,具体目录结构如下:
项目目录结构
- 新建controller目录:这里暂时不考虑打通数据库,接收请求和数据处理全在controller完成。
- 新建confg目录:存放Constant.java——公用参数。
- 新建 data.properties :通过controller获得的数据存在此文件中。
- 新建 util 目录:存放 PropertiesUtil.java ——此类作用是动态向data.properties写入内容。存放 WxUtil.java——此类存放xml和map互转等方法。
导入加解密包
还记的之前说过的企业微信给的加密计算方法实例吗?
微信准备了xml和json两种解密方式。这里选择Java的xml方式。将验证包中的类全部考入 wechataes包 中:
同时pom.xml中需要引入commons.codec包
<!-- commons-codec-->
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
写验证类
此类有两个方法:
- doGetValid : 接收验证请求,用于验证通用开发参数系统事件接收URL、数据回调URL、指令回调URL。当企业微信后台录入回调URL点击保存时,微信服务器会立即发送一条GET请求到对应URL,该函数就对URL的signature进行验证。
- doPostValid: 用于获取 suite_ticket ,以及安装应用时传递过来的 auth_code ,还有用户从企业微信打开应用时也会调用此函数。解密包的第三个参数需做区分:当刷新ticket和安装应用时传递 【SuitID】 ;当打开应用时传递 【CorpID】
再来捋一下,数据回调和指令回调除了保存配置时被访问,还有哪些情况会被微信服务器访问:
- 数据回调URL: 当每次从企业微信打开应用时。【doPostValid 中的解密参数传递CorpID】
- 指令回调URL: 微信服务器推送suite_ticket以及安装应用时推送auth_code时。【doPostValid 中的解密参数传递SuitID】
1、回调验证
doGetValid方法如下:
package com.tan.cwp.controller;
import com.tan.cwp.config.Constant;
import com.tan.cwp.wechataes.WXBizMsgCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
@RestController
@RequestMapping("/verify")
public class VerifyController {
Logger logger = LoggerFactory.getLogger(VerifyController.class);
/*
* 验证通用开发参数及应用回调
*/
@RequestMapping(value = "callback_verify.do" ,method = RequestMethod.GET)
public void doGetValid(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 微信加密签名
String msg_signature = request.getParameter("msg_signature");
// 时间戳
String timestamp = request.getParameter("timestamp");
// 随机数
String nonce = request.getParameter("nonce");
// 随机字符串
// 如果是刷新,需返回原echostr
String echostr = request.getParameter("echostr");
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(Constant.TOKEN, Constant.EncodingAESKey, Constant.CorpID);
String sEchoStr=""; //需要返回的明文
PrintWriter out;
try {
sEchoStr = wxcpt.VerifyURL(msg_signature, timestamp,
nonce, echostr);
logger.info("verifyurl echostr: " + sEchoStr);
// 验证URL成功,将sEchoStr返回
out = response.getWriter();
out.print(sEchoStr);
} catch (Exception e) {
//验证URL失败,错误原因请查看异常
e.printStackTrace();
}
}
}
在后台录入好接口URL保存时,微信后台即会向该接口发送GET请求,并携带四个参数:msg_signature、timestamp、nonce、echostr。
通过 request.getParameter(xxx)获取到他们。
还需要从企业微信后台获取三个固定参数用于计算singnature:TOKEN、EncodingAESKey、CorpID
我把固定参数都放在 配置类Constant 中:
package com.tan.cwp.config;
public class Constant {
// 服务商相关
/**
* 服务商CorpID
*/
public static final String CorpID = "xxxx";
/**
* 服务商身份的调用凭证
*/
public static final String ProviderSecret = "xxxx";
// 应用相关
/**
* 应用的唯一身份标识
*/
public static final String SuiteID = "xxxx";
/**
* 应用的调用身份密钥
*/
public static final String SuiteSecret = "xxxx";
// 回调相关
/**
* 回调/通用开发参数Token, 两者解密算法一样,所以为方便设为一样
*/
public static final String TOKEN = "xxxx";
/**
* 回调/通用开发参数EncodingAESKey, 两者解密算法一样,所以为方便设为一样
*/
public static final String EncodingAESKey = "xxxx";
}
有了这些重要参数就可以通过企业微信准备的包进行验证了。运行项目,并点击申请验证:
都成功返回 echostr,并显示已验证:
通用参数也同样验证成功:
2、获取suite_ticket及auth_code
- suite_ticket: 是用于获取 第三方应用凭证(suite_access_token) 的重要参数,由企业微信后台每过十分钟推送一次给 “指令回调URL”
- auth_code: 是用于获取企业临时授权码的重要参数,由企业安装应用成功时返回
两者获取方式完全相同,微信服务器会通过POST请求向我们服务器传递:msg_signature,timestamp,nonce,echostr四个参数 ,同样通过 request.getParameter(xxx)获取。
同时还会传递一个加密过的xml格式的请求体,通过输入流方式获取该参数,并通过企业微信的验证包进行解密。
具体解密过程查看 doPostValid 方法:
package com.tan.cwp.controller;
import com.tan.cwp.config.Constant;
import com.tan.cwp.util.PropertiesUtil;
import com.tan.cwp.util.WxUtil;
import com.tan.cwp.wechataes.WXBizMsgCrypt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.util.Iterator;
import java.util.Map;
@RestController
@RequestMapping("/verify")
public class VerifyController {
Logger logger = LoggerFactory.getLogger(VerifyController.class);
/**
* 验证通用开发参数
*/
@RequestMapping(value = "callback_verify.do" ,method = RequestMethod.GET)
public void doGetValid(HttpServletRequest request, HttpServletResponse response) throws Exception {
// 微信加密签名
String msg_signature = request.getParameter("msg_signature");
// 时间戳
String timestamp = request.getParameter("timestamp");
// 随机数
String nonce = request.getParameter("nonce");
// 随机字符串
// 如果是刷新,需返回原echostr
String echostr = request.getParameter("echostr");
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(Constant.TOKEN, Constant.EncodingAESKey, Constant.CorpID);
String sEchoStr=""; //需要返回的明文
PrintWriter out;
try {
sEchoStr = wxcpt.VerifyURL(msg_signature, timestamp,
nonce, echostr);
// 验证URL成功,将sEchoStr返回
out = response.getWriter();
out.print(sEchoStr);
} catch (Exception e) {
//验证URL失败,错误原因请查看异常
e.printStackTrace();
}
}
/**
* 刷新 ticket
*/
@RequestMapping(value = "callback_verify.do" ,method = RequestMethod.POST)
public String doPostValid(HttpServletRequest request) throws Exception {
// 微信加密签名
String msg_signature = request.getParameter("msg_signature");
// 时间戳
String timestamp = request.getParameter("timestamp");
// 随机数
String nonce = request.getParameter("nonce");
String type = request.getParameter("type");
String id = "";
// 访问应用和企业回调传不同的ID
if(type.equals("data")){
id = Constant.CorpID;
} else {
id = Constant.SuiteID;
}
WXBizMsgCrypt wxcpt = new WXBizMsgCrypt(Constant.TOKEN, Constant.EncodingAESKey, id);
String postData=""; // 密文,对应POST请求的数据
//1.获取加密的请求消息:使用输入流获得加密请求消息postData
ServletInputStream in = request.getInputStream();
BufferedReader reader =new BufferedReader(new InputStreamReader(in));
String tempStr=""; //作为输出字符串的临时串,用于判断是否读取完毕
while(null!=(tempStr=reader.readLine())){
postData+=tempStr;
}
String suiteXml=wxcpt.DecryptMsg( msg_signature, timestamp, nonce, postData);
logger.info("suiteXml: " + suiteXml);
Map suiteMap = WxUtil.transferXmlToMap(suiteXml);
if(suiteMap.get("SuiteTicket") != null) {
PropertiesUtil.setProperty("suite_ticket", (String) suiteMap.get("SuiteTicket"));
} else if(suiteMap.get("AuthCode") != null){
PropertiesUtil.setProperty("auth_code", (String) suiteMap.get("AuthCode"));
}
String success = "success";
return success;
}
}
注意这段:
我们在数据回调URL加个参数用于判断是用corpid还是suiteid:
最终解密过后都会获取到一个字符串类型的xml,我们用 WxUtil类 中的 parseXml方法 将string格式转为map格式:
package com.tan.cwp.util;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.SAXBuilder;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
public class WxUtil {
/**
* 将 Map 转化为 XML
*
* @param map
* @return
*/
public static String transferMapToXml(SortedMap<String, Object> map) {
StringBuffer sb = new StringBuffer();
sb.append("<xml>");
for (String key : map.keySet()) {
sb.append("<").append(key).append(">")
.append(map.get(key))
.append("</").append(key).append(">");
}
return sb.append("</xml>").toString();
}
/**
* 将 XML 转化为 map
*
* @param strxml
* @return
* @throws IOException
*/
public static Map transferXmlToMap(String strxml) throws IOException {
strxml = strxml.replaceFirst("encoding=\".*\"", "encoding=\"UTF-8\"");
if (null == strxml || "".equals(strxml)) {
return null;
}
Map m = new HashMap();
InputStream in = new ByteArrayInputStream(strxml.getBytes("UTF-8"));
SAXBuilder builder = new SAXBuilder();
Document doc = null;
try {
doc = builder.build(in);
} catch (JDOMException e) {
throw new IOException(e.getMessage()); // 统一转化为 IO 异常输出
}
// 解析 DOM
Element root = doc.getRootElement();
List list = root.getChildren();
Iterator it = list.iterator();
while (it.hasNext()) {
Element e = (Element) it.next();
String k = e.getName();
String v = "";
List children = e.getChildren();
if (children.isEmpty()) {
v = e.getTextNormalize();
} else {
v = getChildrenText(children);
}
m.put(k, v);
}
//关闭流
in.close();
return m;
}
// 辅助 transferXmlToMap 方法递归提取子节点数据
private static String getChildrenText(List<Element> children) {
StringBuffer sb = new StringBuffer();
if (!children.isEmpty()) {
Iterator<Element> it = children.iterator();
while (it.hasNext()) {
Element e = (Element) it.next();
String name = e.getName();
String value = e.getTextNormalize();
List<Element> list = e.getChildren();
sb.append("<" + name + ">");
if (!list.isEmpty()) {
sb.append(getChildrenText(list));
}
sb.append(value);
sb.append("</" + name + ">");
}
}
return sb.toString();
}
}
里面用到了dom4J,需要在pom.xml中引入:
<!--XML 解析包-->
<dependency>
<groupId>org.jdom</groupId>
<artifactId>jdom2</artifactId>
<version>2.0.6</version>
</dependency>
由于并不急着打通数据库,这里通过 PropertiesUtil类 动态的读取和写入获取到的参数到 data.properties中:
package com.tan.cwp.util;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Properties;
public class PropertiesUtil {
private static Logger logger = LoggerFactory.getLogger(PropertiesUtil.class);
private static Properties props;
private static String fileName = "data.properties";
private static String path ="D:/Nobug/WeChat/backend/cwp/src/main/resources/data.properties";
static {
props = new Properties();
try {
props.load(new InputStreamReader(PropertiesUtil.class.getClassLoader().getResourceAsStream(fileName),"UTF-8"));
} catch (IOException e) {
logger.error("配置文件读取异常",e);
}
}
// 读取参数
public static String getProperty(String key){
String value = props.getProperty(key.trim());
if(StringUtils.isBlank(value)){
return null;
}
return value.trim();
}
// 写入参数
public static String setProperty(String key, String value) throws IOException {
props.setProperty(key, value);
FileOutputStream file = new FileOutputStream(path);
props.store(file,"refresh");
return null;
}
}
pom.xml需要引入commons-lang3
<!--StringUtils-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.5</version>
</dependency>
记住要先在data.properties中放好suite_ticket和auth_code:
正确获取到参数后,还需要返回success给微信服务器(注意要将"success"字符串复制给一个变量返回,这是一个坑!)再次运行项目,我们先获取suite_ticket:
写入成功:
注意!!以下安装测试需先通过授权才能安装。授权方法参考下一章[企业微信三方开发(二)的授权配置一节](),建议把下一章全部调通再回过头来测试安装应用!
我们再试下安装测试:
通过企业微信扫码安装后,显示安装成功:
AuthCode也写入:
总结
在第三方应用开发提供的接口中,主要围绕三种类型的access_token进行开发:
- provider_access_token 服务商的token
- suite_access_token 第三方应用的token
- access_token 授权企业的token
通过今天的配置和获取到SuiteTicket和AuthCode后,接下来要做的就是通过一系列HTTP请求来愉快获取这些token!
2020/10/7重要更正:
今天在测试数据回调时发现数据回调后台提示corpid验证失败。原来逻辑是实例化WXBizMsgCrypt类时传递的corpid为服务商的id。通过测试发现要用安装企业的id才行,赶紧回看文档果然有变化。等于说企业微信服务器在访问数据回调url时都会携带上访问的企业id,后台在处理时将corpid取出用作参数就行了!
首先将数据回调URL加上 &corpid=$CORPID$ ,微信服务器会自动将 $CORPID$ 的内容替换为该企业的corpid,我们后台直接获取该参数就行了。