Java版本微信公众号接入(附详细代码流程)
前言
首先要明白公众号号与代公众号的区别。微信提供了微信公众平台与微信开放平台,微信公众平台就是我们常用的公众号,而微信开放平台就是指的代公众号,为第三方平台服务。
所谓的代公众号指的就是将公众号授权给第三方平台进行管理,第三方平台拥有全部或部分的该公众号的接口权限,可以帮助管理运营公众号。
微信公众平台-公众号.png 微信开放平台-代公众号.png
本文非第三方平台接入,楼主在开发公众号与代公众号过程中遇到了不少坑,写这篇文章的目的也是为后人提供一些踩坑指南,文中代码均已经过测试,可以放心食用。
公众平台配置
公众平台配置.png消息加解密方式选择安全模式。启用服务器配置时,微信需要验证所填服务器地址url是否可用,开发时使用内网穿透外网工具。url的接口开发完成后才能保存成功。
- IP白名单不要忘记配置,多个IP换行分隔。如果不知道本机外网IP,请百度关键词IP。
- 如果启用了服务器配置,微信公众平台将不再提供自定义菜单、自动回复等基础功能,平台上原有的菜单将会失效。
场景:公众号由运营人员运营,开发人员需要在自家的产品上嵌入公众号。很尴尬的情况出现了,运营人员不能放弃使用微信公众平台,开发人员需要接入公众号。对于这种情况,可以在公众号关闭服务器配置,将公众号授权给第三方平台,微信会将公众号的事件推送给第三方平台,以第三方平台的方式进行开发。
开发前准备
-
消息加解密工具
微信提供了java版本的加解密的工具类,但是不是maven工程,楼主打包了一份maven工程,内容如上,自行deploy到maven仓库中。
开发配置
-
所需依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--上文打包好的微信加解密工具-->
<dependency>
<groupId>com.hualala</groupId>
<artifactId>aes-util</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.29</version>
</dependency>
<dependency>
<groupId>dom4j</groupId>
<artifactId>dom4j</artifactId>
<version>1.6</version>
</dependency>
-
项目配置
server:
port: 8080
spring:
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 10000
wechat:
appID: #公众号AppID
secret: #公众平台配置的APPSecret
token: #公众平台配置的Token
encodingAESKey: #公众平台配置的EncodingAESKey
/**
* @author YuanChong
* @create 2019-06-26 22:20
* @desc
*/
@Data
@Component
@ConfigurationProperties(prefix = "wechat")
public class WXConfig implements InitializingBean {
private String appID;
private String secret;
private String token;
private String encodingAESKey;
/**
* 微信加解密工具
*/
private WXBizMsgCrypt wxBizMsgCrypt;
/**
* 创建全局唯一的微信加解密工具
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
wxBizMsgCrypt = new WXBizMsgCrypt(this.token, this.encodingAESKey, this.appID);
}
}
/**
* @author YuanChong
* @create 2018-07-04 18:56
* @desc 与微信交互有大量的URL,建议统一保存管理
*/
public class WXConstant {
/**
* 微信公众号access_token_key 用于保存在redis中的key
*/
public static final String ACCESS_TOKEN_KEY = "wechat:accessToken:%s";
/**
* 获取微信公众号的access_token
*/
public static final String WX_ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
}
注意:公众号AppID在项目代码的任何地方都不要写死,保证公众号灵活随时可配置更换
开发
-
获取公众号AccessToken
AccessToken是公众号的唯一凭证,附部分微信文档。楼主把AccessToken存储在了redis中,对外只暴露redis。
- 建议公众号开发者使用中控服务器统一获取和刷新access_token,其他业务逻辑服务器所使用的access_token均来自于该中控服务器,不应该各自去刷新,否则容易造成冲突,导致access_token覆盖而影响业务;
- 目前access_token的有效期通过返回的expire_in来传达,目前是7200秒之内的值。中控服务器需要根据这个有效时间提前去刷新新access_token。在刷新过程中,中控服务器可对外继续输出的老access_token,此时公众平台后台会保证在5分钟内,新老access_token都可用,这保证了第三方业务的平滑过渡;
- access_token的有效时间可能会在未来有调整,所以中控服务器不仅需要内部定时主动刷新,还需要提供被动刷新access_token的接口,这样便于业务服务器在API调用获知access_token已超时的情况下,可以触发access_token的刷新流程。
/**
* @author YuanChong
* @create 2019-06-26 22:47
* @desc
*/
@Component
public class RefreshToken implements InitializingBean {
@Autowired
private WXService wxService;
/**
* 刷新token的定时线程
*/
private ScheduledThreadPoolExecutor scheduledPool = new ScheduledThreadPoolExecutor(1,
new BasicThreadFactory.Builder().namingPattern("refresh-wx-access-token-%d").daemon(true).build());
@Override
public void afterPropertiesSet() {
scheduledPool.scheduleAtFixedRate(() -> wxService.refreshToken(),0, 7000, TimeUnit.SECONDS);
}
}
/**
* @author YuanChong
* @create 2019-06-26 22:51
* @desc
*/
@Log4j2
@Service
public class WXService {
@Autowired
private WXConfig wxConfig;
/**
* 刷新微信公众号的access_token
* https请求:
* https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
* 微信返回数据:
* {"access_token":"ACCESS_TOKEN","expires_in":7200}
* @return
*/
public String refreshToken() {
String redisKey = String.format(WXConstant.ACCESS_TOKEN_KEY,wxConfig.getAppID());
String url = String.format(WXConstant.WX_ACCESS_TOKEN_URL,wxConfig.getAppID(),wxConfig.getSecret());
//HttpClient工具根据项目自行修改
HttpClientUtil.HttpResult result = HttpClientUtil.getInstance().post(url,"");
log.info("获取微信公众号的access_token: {}", result.getContent());
String accessToken = JSON.parseObject(result.getContent()).getString("access_token");
//redis工具根据项目自行修改
CacheUtils.set(redisKey, accessToken, 7200);
return accessToken;
}
}
- 服务器url(微信事件推送)
首先初步接入,保存公众平台上配置的服务器URL。本机开启内网穿透,微信会尝试对接口发送参数echostr,原样返回即可接入成功。
/**
* @author YuanChong
* @create 2019-06-26 21:51
* @desc
*/
@Log4j2
@RestController
@RequestMapping("/notice")
public class NoticeController {
/**
* 公众号消息和事件推送
*
* @param timestamp 时间戳
* @param nonce 随机数
* @param msgSignature 消息体签名
* @param echostr 初次接入配置所需
* @param postData 消息体
* @return
*/
@ResponseBody
@RequestMapping(value = "/event")
public Object official(@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam(value = "msg_signature",required = false) String msgSignature,
@RequestParam(value = "echostr", required = false) String echostr,
@RequestBody(required = false) String postData) throws Exception {
return echostr;
}
}
微信给推送与接收的数据格式均为xml,简单些一个xml与map的转换工具
/**
* @author YuanChong
* @create 2019-06-26 20:48
* @desc xml转换工具
*/
public class XMLParse {
/**
* @param xml 要转换的xml字符串
* @return 转换成map后返回结果
* @throws Exception
*/
public static Map<String, String> xmlToMap(String xml) throws Exception {
Map<String, String> respMap = new HashMap<String, String>();
SAXReader reader = new SAXReader();
Document doc = reader.read(new ByteArrayInputStream(xml.getBytes("utf-8")));
Element root = doc.getRootElement();
xmlToMap(root, respMap);
return respMap;
}
/**
* map对象转行成xml
*
* @param map
* @return
* @throws IOException
*/
public static String mapToXml(Map<String, Object> map) throws IOException {
Document d = DocumentHelper.createDocument();
Element root = d.addElement("xml");
mapToXml(root, map);
StringWriter sw = new StringWriter();
XMLWriter xw = new XMLWriter(sw);
xw.setEscapeText(false);
xw.write(d);
return sw.toString();
}
/**
* 递归转换
*
* @param root
* @param map
* @return
* @throws IOException
*/
private static Element mapToXml(Element root, Map<String, Object> map) throws IOException {
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (entry.getValue() instanceof Map) {
Element element = root.addElement(entry.getKey());
mapToXml(element, (Map<String, Object>) entry.getValue());
} else {
root.addElement(entry.getKey()).addText(entry.getValue().toString());
}
}
return root;
}
/**
* 递归转换
*
* @param tmpElement
* @param respMap
* @return
*/
private static Map<String, String> xmlToMap(Element tmpElement, Map<String, String> respMap) {
if (tmpElement.isTextOnly()) {
respMap.put(tmpElement.getName(), tmpElement.getText());
return respMap;
}
Iterator<Element> eItor = tmpElement.elementIterator();
while (eItor.hasNext()) {
Element element = eItor.next();
xmlToMap(element, respMap);
}
return respMap;
}
}
公众平台上所配置的服务器url是一个非常重要的接口地址,用户关注、取关、自定义菜单点击事件、接收用户消息并作出被动回复等等都会推送到这个接口上,微信以不同的type来区分不同的事件类型,建议以策略模式来写这个接口。
策略上层抽象接口
/**
* @author YuanChong
* @create 2018-07-06 16:21
* @desc 微信事件推送策略的最上层抽象
*/
public interface WeChatNotify {
/**
* 上层事件推送策略抽象接口
*
* @param xmlMap 微信推送的参数数据
* @return 返回给微信的回复信息 如:接收到用户发消息事件,我们给他返回“我收到啦”
* @throws Exception
*/
String weChatNotify(Map<String, String> xmlMap) throws Exception;
}
策略枚举
/**
* @author YuanChong
* @create 2018-07-31 15:27
* @desc 微信的推送类型枚举
*/
public enum NotifyEnum {
//菜单点击事件
CLICK("event", "CLICK"),
//关注
SUBSCRIBE("event", "subscribe"),
//取关
UNSUBSCRIBE("event", "unsubscribe"),
//已关注时的扫码事件
SCAN("event", "SCAN"),
//文字消息回复
TEXT("text", null);
private String msgType;
private String event;
NotifyEnum(String msgType, String event) {
this.msgType = msgType;
this.event = event;
}
public String getMsgType() {
return this.msgType;
}
public String getEvent() {
return this.event;
}
/**
* 解析事件类型
*
* @param msgType
* @param event
* @return
*/
public static NotifyEnum resolveEvent(String msgType, String event) {
for (NotifyEnum notifyEnum : NotifyEnum.values()) {
if (Objects.equals(msgType, notifyEnum.getMsgType()) && Objects.equals(event, notifyEnum.getEvent())) {
return notifyEnum;
}
}
return null;
}
}
策略注解
/**
* @author YuanChong
* @create 2018-07-31 15:27
* @desc 微信的推送类型注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Component
public @interface NotifyType {
/**
* 事件推送的类型 支持枚举多个事件
* @return
*/
NotifyEnum[] value();
}
策略具体实现,针对于不同的事件推送,以微信文档为主
/**
* @author YuanChong
* @create 2018-07-06 17:12
* @desc 策略实现 用户取消关注
*/
@Log4j2
@NotifyType(NotifyEnum.UNSUBSCRIBE)
public static class UnSubscribe implements WeChatNotify {
@Autowired
private WXService wxService;
@Override
public String weChatNotify(Map<String, String> xmlMap) throws Exception {
return "";
}
}
/**
* @author YuanChong
* @create 2019-01-15 17:12
* @desc 策略实现 用户关注事件
*/
@Log4j2
@NotifyType(NotifyEnum.SUBSCRIBE)
public static class Subscribe implements WeChatNotify {
@Autowired
private WXService wxService;
@Override
public String weChatNotify(Map<String, String> xmlMap) throws Exception {
return "";
}
}
策略工厂
/**
* @author YuanChong
* @create 2018-07-31 15:43
* @desc
*/
@Component
public class NotifyFactory implements ApplicationContextAware {
/**
* 策略列表
*/
private Map<NotifyEnum, WeChatNotify> notifyMap = new HashMap<>();
/**
* 工厂获取事件执行策略对象
*
* @param notifyType
* @return
*/
public WeChatNotify loadWeChatNotify(NotifyEnum notifyType) {
WeChatNotify notify = notifyMap.get(notifyType);
//对于没配置的策略 返回一个默认的空实现即可
return Optional.ofNullable(notify).orElse((xmlMap) -> this.defaultNotify(xmlMap));
}
/**
* 工厂提供默认空实现
*
* @param xmlMap
* @return
*/
public String defaultNotify(Map<String, String> xmlMap) {
return "success";
}
/**
* 扫描带有NotifyType注解的bean组装成map
* 新加策略时 在类上加入注解@NotifyType(...)即可
* 支持枚举多个策略事件
*
* @param applicationContext
* @throws BeansException
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, Object> notifyBeanMap = applicationContext.getBeansWithAnnotation(NotifyType.class);
Map<NotifyEnum[], WeChatNotify> annoValueBeanMap = notifyBeanMap.values().stream()
.filter(obj -> ArrayUtils.contains(obj.getClass().getInterfaces(), WeChatNotify.class))
.map(obj -> (WeChatNotify) obj)
.collect(Collectors.toMap(obj -> obj.getClass().getAnnotation(NotifyType.class).value(), Function.identity()));
annoValueBeanMap.entrySet().stream().forEach(enrty -> Arrays.stream(enrty.getKey()).forEach(type -> notifyMap.put(type, enrty.getValue())));
}
}
完善controller接口
/**
* @author YuanChong
* @create 2019-06-26 21:51
* @desc
*/
@Log4j2
@RestController
@RequestMapping("/notice")
public class NoticeController {
@Autowired
private WXConfig wxConfig;
@Autowired
private NotifyFactory notifyFactory;
/**
* 公众号消息和事件推送
*
* @param timestamp 时间戳
* @param nonce 随机数
* @param msgSignature 消息体签名
* @param echostr 初次接入配置所需
* @param postData 消息体
* @return
*/
@ResponseBody
@RequestMapping(value = "/event")
public Object official(@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce,
@RequestParam(value = "msg_signature",required = false) String msgSignature,
@RequestParam(value = "echostr", required = false) String echostr,
@RequestBody(required = false) String postData) throws Exception {
log.info("Msg接收到的POST请求: signature={}, timestamp={}, nonce={}, echostr={} postData={}", msgSignature, timestamp, nonce, echostr,postData);
if(StringUtils.isEmpty(postData)) {
//此处用于公众平台配置的初步接入
return echostr;
}
WXBizMsgCrypt pc = wxConfig.getWxBizMsgCrypt();
//签名校验 数据解密
String decryptXml = pc.decryptMsg(msgSignature, timestamp, nonce, postData);
Map<String, String> decryptMap = XMLParse.xmlToMap(decryptXml);
//获取推送事件类型 可以拿到的事件: 1 关注/取消关注事件 2:扫描带参数二维码事件 3: 用户已经关注公众号 扫描带参数二维码事件 ...等等
NotifyEnum notifyEnum = NotifyEnum.resolveEvent(decryptMap.get("MsgType"), decryptMap.get("Event"));
WeChatNotify infoType = notifyFactory.loadWeChatNotify(notifyEnum);
//执行具体的策略 得到给微信的响应信息 微信有重试机制 需要考虑幂等性
String result = infoType.weChatNotify(decryptMap);
log.info("Msg响应的POST结果: 授权策略对象: [{}] 解密后信息: [{}] 返回给微信的信息: [{}]", infoType.getClass().getSimpleName(), decryptMap, result);
return result;
}
}
加解密异常java.security.InvalidKeyException:illegal Key Size解决方案
微信针对这个异常提供的文档说明
在官方网站下载JCE无限制权限策略文件(JDK7的下载地址)
下载后解压,可以看到local_policy.jar和US_export_policy.jar以及readme.txt,如果安装了JRE,将两个jar文件放到%JRE_HOME%\lib\security目录下覆盖原来的文件;如果安装了JDK,将两个jar文件放到%JDK_HOME%\jre\lib\security目录下覆盖原来文件