工作生活

Java版本微信公众号接入(附详细代码流程)

2019-06-30  本文已影响0人  yuan_dongj

前言

首先要明白公众号号与代公众号的区别。微信提供了微信公众平台与微信开放平台,微信公众平台就是我们常用的公众号,而微信开放平台就是指的代公众号,为第三方平台服务。

所谓的代公众号指的就是将公众号授权给第三方平台进行管理,第三方平台拥有全部或部分的该公众号的接口权限,可以帮助管理运营公众号。


微信公众平台-公众号.png 微信开放平台-代公众号.png

本文非第三方平台接入,楼主在开发公众号与代公众号过程中遇到了不少坑,写这篇文章的目的也是为后人提供一些踩坑指南,文中代码均已经过测试,可以放心食用。

公众平台配置

公众平台配置.png

消息加解密方式选择安全模式。启用服务器配置时,微信需要验证所填服务器地址url是否可用,开发时使用内网穿透外网工具。url的接口开发完成后才能保存成功。

image.png

场景:公众号由运营人员运营,开发人员需要在自家的产品上嵌入公众号。很尴尬的情况出现了,运营人员不能放弃使用微信公众平台,开发人员需要接入公众号。对于这种情况,可以在公众号关闭服务器配置,将公众号授权给第三方平台,微信会将公众号的事件推送给第三方平台,以第三方平台的方式进行开发。

开发前准备

开发配置

        <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在项目代码的任何地方都不要写死,保证公众号灵活随时可配置更换

开发

  1. 建议公众号开发者使用中控服务器统一获取和刷新access_token,其他业务逻辑服务器所使用的access_token均来自于该中控服务器,不应该各自去刷新,否则容易造成冲突,导致access_token覆盖而影响业务;
  2. 目前access_token的有效期通过返回的expire_in来传达,目前是7200秒之内的值。中控服务器需要根据这个有效时间提前去刷新新access_token。在刷新过程中,中控服务器可对外继续输出的老access_token,此时公众平台后台会保证在5分钟内,新老access_token都可用,这保证了第三方业务的平滑过渡;
  3. 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。本机开启内网穿透,微信会尝试对接口发送参数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目录下覆盖原来文件

上一篇下一篇

猜你喜欢

热点阅读