企业微信(网页应用开发)

实现远程验证回调企业微信本地接口服务开发场景

内网穿透

通过使用内网穿透工具提供的公网域名成功验证回调本地接口服务,企业微信开发者在应用的开发测试阶段,应用服务通常是部署在开发环境,在有数据回调的开发场景下,企业微信的回调数据无法直接请求到开发环境的服务。

内网穿透工具可以帮助开发者将应用开发调试过程中的回调请求,穿透到本地的开发环境。Cpolar 是一种安全的内网穿透的服务,可以将内网下的本地服务器通过安全隧道暴露至公网,使得公网用户可以正常访问内网服务,是一款优秀内网穿透软件。

cpolar 官网:https://www.cpolar.com/

安装完成后,启动软件,会在浏览器打开一个网页,输入邮箱、密码登录

/images/posts/企业微信网页应用开发/1.jpg
(图1)

隧道管理 -> 创建隧道,创建一个本地服务的 http 隧道

  • 隧道名称:可自定义命名,注意不要与已有的隧道名称重复
  • 协议:选择 http
  • 本地地址:8080 (回调自己本地服务的端口)
  • 域名类型:免费选择随机域名
  • 地区:选择 China
/images/posts/企业微信网页应用开发/2.jpg
(图2)

隧道创建成功后,状态 -> 在线隧道列表,查看所生成的域名地址,有两种访问方式,分别是 httphttps,域名我们需要记住,稍后在企业微信开发者中心创建应用的时候需要填写该域名!

/images/posts/企业微信网页应用开发/3.jpg
(图3)

进入微信企业开发者中心,登录后,点击上方工具,再点击左侧网页应用开发,进入创建应用界面,点击创建应用

/images/posts/企业微信网页应用开发/4.jpg
(图4)

把要求填写的域名信息,填写上面我们在 cpolar 中创建的域名,然后点击创建应用

/images/posts/企业微信网页应用开发/5.jpg
(图5)

创建项目引入加解密库

使用 xml 版本的

/images/posts/企业微信网页应用开发/6.jpg
(图6)
/images/posts/企业微信网页应用开发/7.jpg
(图7)

解压后,放在 tool 文件夹中

/images/posts/企业微信网页应用开发/8.jpg
(图8)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- 针对org.apache.commons.codec.binary.Base64-->
<dependency>
	<groupId>commons-codec</groupId>
	<artifactId>commons-codec</artifactId>
	<version>1.9</version>
</dependency>

<!-- 使用IOUtils-->
<dependency>
	<groupId>commons-io</groupId>
	<artifactId>commons-io</artifactId>
	<version>2.11.0</version>
</dependency>

<!-- 企业微信json格式包-->
<dependency>
	<groupId>org.json</groupId>
	<artifactId>json</artifactId>
	<version>20200518</version>
</dependency>

<!-- dom4j解析xml -->
<dependency>
	<groupId>org.dom4j</groupId>
	<artifactId>dom4j</artifactId>
	<version>2.1.1</version>
</dependency>

引入加密库后,提示非法字符’\ufeff'

该错误通常发生在尝试编译 Java 源代码文件时,文件开头的字符是一个字节顺序标记(Byte Order Mark,BOM),即 \ufeff。在 Java 中,\ufeff 不是一个合法的字符,因此编译器会报 “非法字符: ‘\ufeff’” 错误。

解决方法

  • Idea 界面右下角处将 utf-8 改为 GBK
  • 在弹出的窗口中点击转换
  • 接着再重新将编码格式改回 utf-8 就可以啦

验证可信域名

下载可信域名验证文件,并放到静态目录下

/images/posts/企业微信网页应用开发/9.jpg
(图9)

验证回调

企业微信会先判断 URL 服务是否具备解析企业微信推送消息的能力,然后再发送业务回调,也就是连着发送两次请求,所以在 command 方法中,需要判断请求的类型,如果是 get,表示该请求用来验证是否具备解析能力,如果是 post,表示该请求用来执行业务回调

/images/posts/企业微信网页应用开发/10.jpg
(图10)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@RestController
@RequestMapping(value = "/callback")
public class WeChatConfig {

    /**
     * 3.1 支持Http Get请求验证URL有效性
     */
    @GetMapping("/data")
    public String data(HttpServletRequest request) throws IOException {
        String sEchoStr = "";

        String sVerifyMsgSig = request.getParameter("msg_signature");
        String sVerifyTimeStamp = request.getParameter("timestamp");
        String sVerifyNonce = request.getParameter("nonce");
        String sVerifyEchoStr = request.getParameter("echostr");

        try {
            WXBizMsgCrypt wxcpt = new WXBizMsgCrypt("Nqyyj3yLDT4R2Q6idhtTJ2tP8t", "F20sj9GfH8lx66HKirERy5zjoMCDGHdugTXVmOvXJ7f", "");

            sEchoStr = wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp, sVerifyNonce, sVerifyEchoStr);
            System.out.println("verifyurl echostr: " + sEchoStr);
            // 验证URL成功,将sEchoStr返回
//            response.getWriter().write(sEchoStr);
            return sEchoStr;
        } catch (Exception e) {
            //验证URL失败,错误原因请查看异常
            e.printStackTrace();
//            response.getWriter().write("failed");
            return "failed";
        }
    }

    /**
     * 3.2 支持Http Post请求接收业务数据
     *
     * @return
     */
    @RequestMapping(value = "/command", method = {RequestMethod.GET, RequestMethod.POST})
    public String command(HttpServletRequest request, @RequestBody(required = false) String body) throws Exception {
        // 判断请求方法
        String method = request.getMethod();
        if ("GET".equalsIgnoreCase(method)) {
            return data(request);
        } else {
            String sReqMsgSig = request.getParameter("msg_signature");
            String sReqTimeStamp = request.getParameter("timestamp");
            String sReqNonce = request.getParameter("nonce");

            try {
                //wwfb662f05212e49eb是应用id
                WXBizMsgCrypt wxcpt = new WXBizMsgCrypt("Nqyyj3yLDT4R2Q6idhtTJ2tP8t", "F20sj9GfH8lx66HKirERy5zjoMCDGHdugTXVmOvXJ7f", "wwfb662f05212e49eb");
                //解密
                String sMsg = wxcpt.DecryptMsg(sReqMsgSig, sReqTimeStamp, sReqNonce, body);
                System.out.println("解密后的数据:" + sMsg);
                //将post数据转换为map
                Map<String, String> dataMap = MessageUtil.parseXml(sMsg);
                System.out.println("转为map的数据:" + dataMap.toString());

                return "success";
            } catch (AesException e) {
                throw new RuntimeException(e);
            }
        }
    }
}
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package com.example.demo.tool;

import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MessageUtil {

    /**
     * 返回消息类型:文本.
     */
    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 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_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";

    /**
     * 事件类型:taskcard_click(点击任务卡片按钮).
     */
    public static final String EVENT_TYPE_TASKCARD_CLICK = "taskcard_click";

    /**
     * 事件类型:open_approval_change(审批状态通知事件).
     */
    public static final String EVENT_TYPE_OPEN_APPROVAL_CHANGE = "open_approval_change";

    public static final String EVENT_TYPE_ENTER_AGENT = "enter_agent";

    /**
     * 解析微信发来的请求(XML).
     *
     * @param msg 消息
     * @return map
     */
    @SuppressWarnings("unchecked")
    public static Map<String, String> parseXml(final String msg) {
        // 将解析结果存储在HashMap中
        Map<String, String> map = new HashMap<String, String>();

        // 从request中取得输入流
        try (InputStream inputStream = new ByteArrayInputStream(msg.getBytes(StandardCharsets.UTF_8.name()))) {
            // 读取输入流
            SAXReader reader = new SAXReader();
            Document document = reader.read(inputStream);
            // 得到xml根元素
            Element root = document.getRootElement();
            // 得到根元素的所有子节点
            List<Element> elementList = root.elements();

            // 遍历所有子节点
            for (Element e : elementList) {
                map.put(e.getName(), e.getText());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return map;
    }
}

注意

数据回调 URL 申请校验不通过
加解密库里,ReceiveId 在各个场景的含义不同:

  • 企业应用的回调,表示 corpid
  • 第三方事件的回调,表示 suiteid
  • 个人主体的第三方应用的回调,ReceiveId 是一个空字符串
    /images/posts/企业微信网页应用开发/13.jpg
    (图13)

应用安装测试,应用添加失败

/images/posts/企业微信网页应用开发/11.jpg
(图11)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@RestController
public class WechatController {

    @Autowired
    private WeChatConfig weChatConfig;

    /**
     * 安装测试应用,对微信的请求进行响应
     * @param request
     * @param body
     * @return
     * @throws Exception
     */
    @RequestMapping(value = "/callback", method = {RequestMethod.GET, RequestMethod.POST})
    public String callback(HttpServletRequest request, @RequestBody(required = false) String body) throws Exception {
        return weChatConfig.command(request,body);
    }
}

企业微信网页应用的局限性

权限少,什么都干不了

/images/posts/企业微信网页应用开发/12.jpg
(图12)
/images/posts/企业微信网页应用开发/14.jpg
(图14)
/images/posts/企业微信网页应用开发/15.jpg
(图15)

0%