钉钉H5微应用开发指南

我们在这一片文章https://www.cnblogs.com/zhenjingcool/p/16896198.html中对钉钉开放平台进行了简略介绍,钉钉开放平台为我们提供了5种开放能力,即应用开发、工作台开放、群开放、连接平台、智能硬件接入。

这里我们详细介绍这5部分中的其中之一:应用开发。而且是应用开发中的H5微应用。

1 总概

在使用钉钉开放平台的能力开发应用前,请注意:

  1. 调用钉钉服务端接口时,需使用HTTPS协议、JSON数据格式、UTF-8编码,POST请求请在HTTP Header中设置 Content-Type:application/json。

    访问域名为:

    • 新版服务端接口:https://api.dingtalk.com

    • 旧版服务端接口:https://oapi.dingtalk.com

  2. 在调用服务端接口时,有调用频率限制。比如每个IP允许调用钉钉提供的接口6000次/20秒、机器人发送信息限制为20条/分钟等等。

  3. 在调用服务端接口时,需要提前设置了对应的接口权限

  4. 必须接入钉钉免登,即在用户打开应用时可直接获取用户身份无需输入钉钉账号和密码。

  5. H5微应用需要进行JSAPI鉴权。详情请参考https://open.dingtalk.com/document/orgapp-client/jsapi-authentication

  6. 推荐配置事件订阅。钉钉会向应用推送订阅的事件,例如部门变更、签到通知、打卡通知等。通过订阅这些事件,开发者可以更好地与钉钉集成。

2 开发流程

一个H5微应用从开发到发布的全部过程,参考:https://open.dingtalk.com/document/org/microapplication-creation-and-release-process

3 dingtalk-design-cli

dingtalk-design-cli是钉钉前端应用研发命令行工具,其作用如下

  • 提供小程序、H5微应用、工作台组件的初始化能力。

  • 提供小程序和工作台组件的本地构建、开发调试、预览、校验和上传等能力。

  • 提供H5微应用本地模拟器开发的能力

3.1 安装

$ npm i dingtalk-design-cli@latest -g --registry=https://registry.npm.taobao.org
//查看是否已经成功安装
$ ding -v
- dingtalk-design-cli: 1.0.7

3.2 使用

 $ ding <command> [options] 

其中<command>有:

命令

说明

init

初始化一个小程序、H5微应用、工作台组件,项目目录下会包含一个ding.config.json配置文件。

dev

开发调试小程序、H5微应用、工作台组件。

preview

生成小程序、工作台组件的调试二维码。

upload

上传小程序、工作台组件项目到开发者后台。

lint

校验小程序、h5微应用、工作台组件。

 
说明 

本地校验时,小程序、H5微应用的校验规则是项目目录中的eslint配置文件。工作台组件的校验规则等同于提交上架会进行的校验规则。

[options]有(以init命令为例)

  • -a, --appType <appType>:(可选)指定应用类型,值可以为mp | h5 | plugin。

  • -t, --template <template>:(可选)指定模版,模版的key可以从Git上查阅,例如:plugin_default,则模版key为default。

  • -l, --language <language>:(可选)指定模版语言,值可以为javascript | typescript(有些模版可能没有typescript语言版本)。

  • --skip-install <skip-install>:(可选)若指定则不自动安装依赖。

  • -o, --outDir <outDir>:(可选)输出目录,若不指定时,将默认在当前目录新建。

  • --cwd [cwd]:(可选)当前的工作目录, 默认值是process.cwd()

更加详细的命令和参数说明,请参考官网https://open.dingtalk.com/document/resourcedownload/introduction

这里重点说明一下-t参数,这个参数作用是指定模板,其中可用模板在git上列出https://github.com/open-dingtalk/dd-application-template

4 创建vue应用

这里前端我们使用vue框架。下面展示使用dingtalk-design-cli创建前端项目的详细过程

这里我们创建了一个使用vue框架和dingtalk-design组件库的项目。

启动项目

使用命令ding dev启动项目

 

浏览器打开效果如下

同时,dingtalk-design-cli为我们准备了微应用本地开发工具,会在本地10003端口打开,如下

我们点击页面最上方的“登陆”按钮,扫码登陆。(经过尝试在公司网络情况下,猜测由于处于内网环境做了网络端口方面的限制,登陆按钮登陆不了)

然后选择企业

 

然后,我们点击JSAPI这个tab页,查看打印的日志信息,说明我们通过JSAPI调用钉钉原生组件和服务成功

但是问题来了,当我们在钉钉后台发布这个应用之后,我们使用钉钉打开这个应用,由于默认部署到本机localhost:3000,所以会报错。解决方案是发布到一个有外网权限的节点。具体做法见下一小结

5 JSAPI鉴权

上面我们已经创建了一个H5微应用的页面。钉钉为我们提供了很多后台api和原生组件。都需要我们使用JSAPI进行调用。而这些api有一些是不需要鉴权就可以调用的,但是绝大部分api需要进行鉴权。

官网上为我们列出了所有的JSAPI接口:https://open.dingtalk.com/document/orgapp-client/jsapi-overview,见下图

 

同时,官网介绍了JSAPI鉴权的过程:https://open.dingtalk.com/document/orgapp-client/jsapi-authentication

下面我们在上面vue项目的基础上加上鉴权

5.1 前端

修改App.vue组件,这里只展示了新增的代码。

我们在组件的created生命周期钩子中调用getAuth函数,这个函数会调用后台接口,这个后台接口会获取和钉钉交互的access_token,以及校验签名等一些和钉钉交互必须做的事情。

然后我们使用dd.config进行鉴权,这里会传递一个对象进去,其中这个对象中有一个jsApiList参数,这个jsApiList参数是需要权限的接口列表,这里暂时写空

dd.error为鉴权失败时会调用这里的回调方法

<script>
import * as dd from 'dingtalk-jsapi'
import { queryAuthSign } from './api/api'

export default {
  created() {
    this.getAuth()
  },
  methods: {
    getAuth() {
      queryAuthSign({}).then(response => {
        console.log('response_________', response)
        dd.config({
          agentId: response.data.agentId, // 必填,微应用ID
          corpId: response.data.corpId,//必填,企业ID
          timeStamp: response.data.timeStamp, // 必填,生成签名的时间戳
          nonceStr: response.data.nonceStr, // 必填,自定义固定字符串。
          signature: response.data.signature, // 必填,签名
          type:0,   //选填。0表示微应用的jsapi,1表示服务窗的jsapi;不填默认为0。该参数从dingtalk.js的0.8.3版本开始支持
          jsApiList : [

          ] // 必填,需要使用的jsapi列表,注意:不要带dd。
        });
        dd.error(function (err) {
          alert('dd error: ' + JSON.stringify(err));
          console.log('dd error:', err);
        });//该方法必须带上,用来捕获鉴权出现的异常信息,否则不方便排查出现的问题
      }).catch(err => {
        console.log('err:', err);
        // this.pageLoading = false
        // this.$message.error('提交失败')
      })
    }
  }
}
</script>

api.js

import request from '@/utils/request'
export function queryAuthSign(data) {
    return request({
        url: '/issue/api/queryAuthSign',
        method: 'post',
        data: data,
        header: { 'Content-Type': 'application/json; charset=utf-8' }
    })
}

request.js

import axios from 'axios'
// create an axios instance
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  timeout: 5000 // request timeout
})
// request interceptor
service.interceptors.request.use(
  config => {
    console.log('-------config', config)
    return config
  },
  error => {
    // do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)
// response interceptor
service.interceptors.response.use(
  response => {
    console.log('响应拦截器response', response)
    return response
  },
  error => {
    console.log('err^^^', error.toString()) // for debug
  }
)
export default service

5.2 后端

后端,我么使用springboot。

这里我们定义了一个接口供前端调用,以便完成JSAPI鉴权

@RestController
@RequestMapping("/issue/api")
public class IndexController {
    private static final Logger LOGGER = LoggerFactory.getLogger(IndexController.class);

    @Autowired
    private RestTemplate restTemplate;

    @RequestMapping(value = "/queryAuthSign", method = RequestMethod.POST)
    @ResponseBody
    public Object queryAuthSign(@RequestBody Map params) {
        LOGGER.info("requestAuthCode:{}", params.toString());

        /**
         * 随机字符串
         */
        String nonceStr = "abcdefg";
        long timeStamp = System.currentTimeMillis() / 1000;
        String signedUrl = "http://xxx.xxx.com/issue/";
        String accessToken = null;
        String ticket = null;
        String signature = null;

        Map accessTokenMap = getAccessToken();

        accessToken = accessTokenMap.get("access_token").toString();
        LOGGER.info("accessToken:{}", accessToken);
        Map jsapiTicketMap = getJsapiTicket(accessToken);
        LOGGER.info("jsapiTicketMap:{}", JSON.toJSONString(jsapiTicketMap));

        ticket = jsapiTicketMap.get("ticket").toString();
        LOGGER.info("ticket:{}", ticket);

        signature = sign(ticket, nonceStr, timeStamp, signedUrl);

        Map<String, Object> configValue = new HashMap<>();
        configValue.put("jsticket", ticket);
        configValue.put("signature", signature);
        configValue.put("nonceStr", nonceStr);
        configValue.put("timeStamp", timeStamp);
        configValue.put("corpId", Env.CORP_ID);
        configValue.put("agentId", Env.AGENT_ID);

//        String config = JSON.toJSONString(configValue);
//        return config;
        return configValue;
    }

    private Map getAccessToken() {
        return this.restTemplate.getForObject("https://oapi.dingtalk.com/gettoken?appkey=" + Env.APP_KEY + "&appsecret=" + Env.APP_SECRET, Map.class);
    }

    private Map getJsapiTicket(String accessToken) {
        return this.restTemplate.getForObject("https://oapi.dingtalk.com/get_jsapi_ticket?access_token=" + accessToken, Map.class);
    }

    private String sign(String ticket, String nonceStr, long timeStamp, String url) {
        try {
            return DingTalkJsApiSingnature.getJsApiSingnature(url, nonceStr, timeStamp, ticket);
        } catch (DingTalkEncryptException ex) {

        }
        return null;
    }
}

5.3 部署

由于本地部署,会有各种各样的问题,比如说1、钉钉客户端访问时实际上访问的是localhost:3000;2、公司内网环境下本地调试会有各种接口不通的情况

我们选择开发阶段就把它部署上线,在生产环境下进行开发调试(可能有更好的方式,但是我摸索了1天没找到更好的方式),虽然这种方式不方便而且低效,但至少工作能够向前推进

我们找到一台有外网权限的机器(一个在用的阿里云节点)把前端部署在这上面

nginx配置

我们配置url以/issue开头时进入我们钉钉H5微应用前端。并且配置对应的后端为/issue/api

    server {
        listen       80;
        server_name  16s9;
        charset utf-8;
        client_max_body_size  200m;
        limit_req zone=one burst=30 nodelay;
        location / {
                //之前部署的应用
        }
        location /api {
            //之前部署的应用后台
        }
        //我们新部署的应用
        location /issue {
                if ($request_filename ~* ^.*?\.(doc|pdf|zip|docx)$) {
                        add_header  Content-Disposition attachment; #下载文件时页面上的格式,有inline和attachment分别表示内嵌和下载
                        add_header  Content-Type application/octet-stream;#octet-stream为通用类型
                }
                try_files $uri /issue/index.html;
                add_header    Cache-Control  public;
                add_header    Cache-Control no-cache;
                add_header    Pragma no-cache;
                alias  /root/issue/dist/;
                index  index.html;
        }
        location /issue/api {
            proxy_http_version 1.1;
            proxy_pass  http://16s3:8089;
            proxy_set_header Connection "";
            proxy_set_header Access-Control-Allow-Origin *;
            proxy_pass_header Server;
            proxy_set_header Host $http_host;
            proxy_redirect off;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Scheme $scheme;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_ignore_client_abort on;
        }
    }

前端部署到16s9:/root/issue/dist

后端部署到16s3:8089

,至此,JSAPI鉴权和部署都完成了,我们在PC端打开应用查看,页面出来了,也没报错。

 

但是,我们切换到JSAPI tab页,发现选择时间控件和选择部门功能都没反应,这可咋整啊,尴尬了,没法调试了。

解决办法,我们使用钉钉为我们提供的调试工具,在【开发管理-调试工具】这里

 

我们修改vue项目index.html

    <script src="https://g.alicdn.com/code/npm/@ali/dingtalk-h5-remote-debug-sdk/0.1.3/app.bundle.js"></script>
    <script>
      h5RemoteDebugSdk.init({
        uuid: "87e07xxxxxxxxxxxxxxxxxx23cb",
        observerElement: document.documentElement,
      });
    </script>

重新部署前端

然后我们再在PC端钉钉打开H5微应用,点击时间选择控件和部门选择功能,这次,错误信息很明白的打印了出来,有了报错信息接下来怎么做我们也知道了

6 配置免登

免登是指用户进入应用后,无需输入钉钉用户名和密码,应用程序可自动获取当前用户身份,进而登录系统的流程

步骤如下:

6.1 获取微应用免登授权码

dd.ready(function() {
    dd.runtime.permission.requestAuthCode({
        corpId: "ding12345xxx", // 企业id
        onSuccess: function (info) {
                  code = info.code // 通过该免登授权码可以获取用户身份
        }});
});

我们把上面获取的免登授权码放到App.vue的data里面

6.2 获取用户信息

做法是,在watch里面添加一个对code的监听,当授权码被赋值后,调用getUserInfo方法获取用户信息

  data() {
    return {
      code: ''
    };
  },
  watch: {
    code:function(newValue,oldValue) {
      if(newValue !== '')
      {
        this.getUserInfo()
      }
    }
  },
  methods: {
    getUserInfo() {
      queryUserInfo({ 'code': this.code }).then(response => {
        console.log('queryUserInfo response_________', response)
      }).catch(err => {
        console.log('queryUserInfo err:', err);
      })
    }
  }

api.js

export function queryUserInfo(data) {
    console.log('请求参数data', data)
    return request({
        url: '/issue/api/queryUserInfo',
        method: 'post',
        data: data,
        header: { 'Content-Type': 'application/json; charset=utf-8' }
    })
}

springboot接口

    @RequestMapping(value = "/queryUserInfo", method = RequestMethod.POST)
    @ResponseBody
    public Object queryUserInfo(@RequestBody Map params) {
        LOGGER.info("queryUserInfo:{}", params.toString());

        try {
            DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/topapi/v2/user/getuserinfo");
            OapiUserGetuserinfoRequest req = new OapiUserGetuserinfoRequest();
            req.setCode(params.get("code").toString());
            Map accessTokenMap = getAccessToken();
            String accessToken = accessTokenMap.get("access_token").toString();
            LOGGER.info("accessToken:{}", accessToken);
            OapiUserGetuserinfoResponse rsp = rsp = client.execute(req, accessToken);
            System.out.println(rsp.getBody());
            LOGGER.info("rsp.getBody():{}", rsp.getBody());
            return rsp.getBody();
        } catch (ApiException e) {
            LOGGER.error("e", e);
//            throw new RuntimeException(e);
        }

        return null;
    }

如下是调用结果

至此,H5微应用开发流程基本完成,后续就是增量开发的过程了,后续的工作和开发vue应用没有区别了

7 后续的增量开发

7.1 通讯录选人

有一个需求需要使用jsapi来调用钉钉通讯录选人组件

我们可以直接在我们自己的vue代码中调用jsapi

dd.biz.contact.complexPicker({
        title:"测试标题",            //标题
        corpId:corp_id,              //企业的corpId
        multiple:true,            //是否多选
        limitTips:"超出了",          //超过限定人数返回提示
        maxUsers:1000,            //最大可选人数
        pickedUsers:[],            //已选用户
        pickedDepartments:[],          //已选部门
        disabledUsers:[],            //不可选用户
        disabledDepartments:[],        //不可选部门
        requiredUsers:[],            //必选用户(不可取消选中状态)
        requiredDepartments:[],        //必选部门(不可取消选中状态)
        appId:agent_id,              //微应用Id,企业内部应用查看AgentId
        permissionType:"GLOBAL",          //可添加权限校验,选人权限,目前只有GLOBAL这个参数
        responseUserOnly:false,        //返回人,或者返回人和部门
        startWithDepartmentId:0 ,   //仅支持0和-1
        onSuccess: function(result) {
          console.log('成功了,选人结果result', result)
          /**
           {
            selectedCount:1,                              //选择人数
            users:[{"name":"","avatar":"","emplId ":""}],//返回选人的列表,列表中的对象包含name(用户名),avatar(用户头像),emplId(用户工号)三个字段
            departments:[{"id":,"name":"","number":}]//返回已选部门列表,列表中每个对象包含id(部门id)、name(部门名称)、number(部门人数)
        }
           */
        },
        onFail : function(err) {
          console.error('失败了,选人结果err', err)
        }
      });

运行结果如下

 

 

后台打印的日志

成功了,选人结果result {departments: Array(0), selectedCount: 1, users: Array(1)}

7.2 配置酷应用

创建酷应用,要基于某个H5微应用来创建,所以,我们先找到这个H5微应用的后台配置页面。然后按照如下截图的指示来进行创建酷应用

 

 

 点击创建酷应用

 

 

我们分别填写基础信息,功能设计,功能开发,预览发布来创建一个酷应用。

其中功能设计是重点,这里我们可以设计快捷入口、消息卡片等信息

 

 

消息卡片的开发和配置请参考 7.3 事件与回调 小节

7.3 事件与回调

钉钉中我们和以对钉钉事件进行订阅,当事件发生时,我们可以做一些干预。比如说,当我们在一个群中安装了某一个酷应用,我们可以感知到这个事件,并向群中发送一些文字信息或者互动卡片等等。

 

 

 当我们配置了aes_key、token、请求网址,点击保存,钉钉将自动向我们配置的网址发送post请求,以便验证配置信息和回调接口的连通性。需要注意的是,回调接口必须部署在公网,中间不能有代理,不然会报如下错误“URL地址在安全黑名单中不允许使用”

我们需要引入如下包

<dependency>
            <groupId>com.aliyun</groupId>
            <artifactId>dingtalk</artifactId>
            <version>1.4.53</version>
        </dependency>

后端接口,在这个接口中,将会对页面上配置的aes_key、token以及app_key进行校验,校验通过后返回经过加密的“success”字符串。页面收到后端返回的字符串后,如果校验通过,将会成功保存aes_key和token到钉钉端。并且显式“事件订阅”页面,下面会讲。

@RestController
@RequestMapping("/issue/api")
public class EventController {
    private static final Logger LOGGER = LoggerFactory.getLogger(EventController.class);

    @Value("${event.aes_key}")
    private String event_aes_key;
    @Value("${event.token}")
    private String event_token;
    @Value("${dingtalk.corp_id}")
    private String CORP_ID;
    @Value("${dingtalk.app_key}")
    private String app_key;

    @RequestMapping(value = "/event", method = RequestMethod.POST)
    @ResponseBody
    public Object event(@RequestParam(value = "msg_signature", required = false) String msg_signature,
                        @RequestParam(value = "timestamp", required = false) String timeStamp,
                        @RequestParam(value = "nonce", required = false) String nonce,
                        @RequestBody Map params) {
        LOGGER.info("params:{}", JsonUtil.objectToJson(params));
        LOGGER.info("app_key:{}", app_key);
        LOGGER.info("event_aes_key:{}", event_aes_key);
        LOGGER.info("event_token:{}", event_token);
        LOGGER.info("msg_signature:{}", msg_signature);
        LOGGER.info("timeStamp:{}", timeStamp);
        LOGGER.info("nonce:{}", nonce);

        if(null == params.get("encrypt") || "".equals(params.get("encrypt"))) {
            return null;
        }
        String encrypt = params.get("encrypt").toString();

        DingCallbackCrypto dingCallbackCrypto = null;
        try {
            // 2. 使用加解密类型
            dingCallbackCrypto = new DingCallbackCrypto(event_token, event_aes_key, app_key);
            String decryptMsg = dingCallbackCrypto.getDecryptMsg(msg_signature, timeStamp, nonce, encrypt);
            LOGGER.info("decryptMsg:{}", decryptMsg);
            // 3. 反序列化回调事件json数据
            JSONObject eventJson = JSON.parseObject(decryptMsg);
            String eventType = eventJson.getString("EventType");

            // 4. 根据EventType分类处理
            if ("check_url".equals(eventType)) {
                // 测试回调url的正确性
            } else if ("user_add_org".equals(eventType)) {
                // 处理通讯录用户增加时间
            } else {
                // 添加其他已注册的
            }

            // 5. 返回success的加密数据
            Map<String, String> successMap = dingCallbackCrypto.getEncryptedMap("success");
            LOGGER.info("successMap:{}", JsonUtil.objectToJson(successMap));
            return successMap;
        } catch (DingCallbackCrypto.DingTalkEncryptException e) {
            LOGGER.info("e", e);
        }
        return null;
    }
}

工具类,(来自于https://github.com/open-dingtalk/dingtalk-callback-Crypto)

public class DingCallbackCrypto {

    private static final Charset CHARSET = Charset.forName("utf-8");
    private static final Base64 base64 = new Base64();
    private byte[] aesKey;
    private String token;
    private String corpId;
    /**
     * ask getPaddingBytes key固定长度
     **/
    private static final Integer AES_ENCODE_KEY_LENGTH = 43;
    /**
     * 加密随机字符串字节长度
     **/
    private static final Integer RANDOM_LENGTH = 16;

    /**
     * 构造函数
     *
     * @param token          钉钉开放平台上,开发者设置的token
     * @param encodingAesKey 钉钉开放台上,开发者设置的EncodingAESKey
     * @param corpId         企业自建应用-事件订阅, 使用appKey
     *                       企业自建应用-注册回调地址, 使用corpId
     *                       第三方企业应用, 使用suiteKey
     *
     * @throws DingTalkEncryptException 执行失败,请查看该异常的错误码和具体的错误信息
     */
    public DingCallbackCrypto(String token, String encodingAesKey, String corpId) throws DingTalkEncryptException {
        if (null == encodingAesKey || encodingAesKey.length() != AES_ENCODE_KEY_LENGTH) {
            throw new DingTalkEncryptException(DingTalkEncryptException.AES_KEY_ILLEGAL);
        }
        this.token = token;
        this.corpId = corpId;
        aesKey = Base64.decodeBase64(encodingAesKey + "=");
    }

    public Map<String, String> getEncryptedMap(String plaintext) throws DingTalkEncryptException {
        return getEncryptedMap(plaintext, System.currentTimeMillis(), Utils.getRandomStr(16));
    }

    /**
     * 将和钉钉开放平台同步的消息体加密,返回加密Map
     *
     * @param plaintext 传递的消息体明文
     * @param timeStamp 时间戳
     * @param nonce     随机字符串
     * @return
     * @throws DingTalkEncryptException
     */
    public Map<String, String> getEncryptedMap(String plaintext, Long timeStamp, String nonce)
        throws DingTalkEncryptException {
        if (null == plaintext) {
            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_PLAINTEXT_ILLEGAL);
        }
        if (null == timeStamp) {
            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_TIMESTAMP_ILLEGAL);
        }
        if (null == nonce) {
            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_NONCE_ILLEGAL);
        }
        // 加密
        String encrypt = encrypt(Utils.getRandomStr(RANDOM_LENGTH), plaintext);
        String signature = getSignature(token, String.valueOf(timeStamp), nonce, encrypt);
        Map<String, String> resultMap = new HashMap<String, String>();
        resultMap.put("msg_signature", signature);
        resultMap.put("encrypt", encrypt);
        resultMap.put("timeStamp", String.valueOf(timeStamp));
        resultMap.put("nonce", nonce);
        return resultMap;
    }

    /**
     * 密文解密
     *
     * @param msgSignature 签名串
     * @param timeStamp    时间戳
     * @param nonce        随机串
     * @param encryptMsg   密文
     * @return 解密后的原文
     * @throws DingTalkEncryptException
     */
    public String getDecryptMsg(String msgSignature, String timeStamp, String nonce, String encryptMsg)
        throws DingTalkEncryptException {
        //校验签名
        String signature = getSignature(token, timeStamp, nonce, encryptMsg);
        if (!signature.equals(msgSignature)) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
        }
        // 解密
        String result = decrypt(encryptMsg);
        return result;
    }

    /*
     * 对明文加密.
     * @param text 需要加密的明文
     * @return 加密后base64编码的字符串
     */
    private String encrypt(String random, String plaintext) throws DingTalkEncryptException {
        try {
            byte[] randomBytes = random.getBytes(CHARSET);
            byte[] plainTextBytes = plaintext.getBytes(CHARSET);
            byte[] lengthByte = Utils.int2Bytes(plainTextBytes.length);
            byte[] corpidBytes = corpId.getBytes(CHARSET);
            ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
            byteStream.write(randomBytes);
            byteStream.write(lengthByte);
            byteStream.write(plainTextBytes);
            byteStream.write(corpidBytes);
            byte[] padBytes = PKCS7Padding.getPaddingBytes(byteStream.size());
            byteStream.write(padBytes);
            byte[] unencrypted = byteStream.toByteArray();
            byteStream.close();
            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
            IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
            byte[] encrypted = cipher.doFinal(unencrypted);
            String result = base64.encodeToString(encrypted);
            return result;
        } catch (Exception e) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_ENCRYPT_TEXT_ERROR);
        }
    }

    /*
     * 对密文进行解密.
     * @param text 需要解密的密文
     * @return 解密得到的明文
     */
    private String decrypt(String text) throws DingTalkEncryptException {
        byte[] originalArr;
        try {
            // 设置解密模式为AES的CBC模式
            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
            IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
            cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
            // 使用BASE64对密文进行解码
            byte[] encrypted = Base64.decodeBase64(text);
            // 解密
            originalArr = cipher.doFinal(encrypted);
        } catch (Exception e) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_ERROR);
        }

        String plainText;
        String fromCorpid;
        try {
            // 去除补位字符
            byte[] bytes = PKCS7Padding.removePaddingBytes(originalArr);
            // 分离16位随机字符串,网络字节序和corpId
            byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
            int plainTextLegth = Utils.bytes2int(networkOrder);
            plainText = new String(Arrays.copyOfRange(bytes, 20, 20 + plainTextLegth), CHARSET);
            fromCorpid = new String(Arrays.copyOfRange(bytes, 20 + plainTextLegth, bytes.length), CHARSET);
        } catch (Exception e) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_LENGTH_ERROR);
        }

        // corpid不相同的情况
        if (!fromCorpid.equals(corpId)) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_CORPID_ERROR);
        }
        return plainText;
    }

    /**
     * 数字签名
     *
     * @param token     isv token
     * @param timestamp 时间戳
     * @param nonce     随机串
     * @param encrypt   加密文本
     * @return
     * @throws DingTalkEncryptException
     */
    public String getSignature(String token, String timestamp, String nonce, String encrypt)
        throws DingTalkEncryptException {
        try {
            String[] array = new String[] {token, timestamp, nonce, encrypt};
            Arrays.sort(array);
            System.out.println(JSON.toJSONString(array));
            StringBuffer sb = new StringBuffer();
            for (int i = 0; i < 4; i++) {
                sb.append(array[i]);
            }
            String str = sb.toString();
            System.out.println(str);
            MessageDigest md = MessageDigest.getInstance("SHA-1");
            md.update(str.getBytes());
            byte[] digest = md.digest();

            StringBuffer hexstr = new StringBuffer();
            String shaHex = "";
            for (int i = 0; i < digest.length; i++) {
                shaHex = Integer.toHexString(digest[i] & 0xFF);
                if (shaHex.length() < 2) {
                    hexstr.append(0);
                }
                hexstr.append(shaHex);
            }
            return hexstr.toString();
        } catch (Exception e) {
            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
        }
    }

    public static class Utils {
        public Utils() {
        }

        public static String getRandomStr(int count) {
            String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
            Random random = new Random();
            StringBuffer sb = new StringBuffer();

            for (int i = 0; i < count; ++i) {
                int number = random.nextInt(base.length());
                sb.append(base.charAt(number));
            }

            return sb.toString();
        }

        public static byte[] int2Bytes(int count) {
            byte[] byteArr = new byte[] {(byte)(count >> 24 & 255), (byte)(count >> 16 & 255), (byte)(count >> 8 & 255),
                (byte)(count & 255)};
            return byteArr;
        }

        public static int bytes2int(byte[] byteArr) {
            int count = 0;

            for (int i = 0; i < 4; ++i) {
                count <<= 8;
                count |= byteArr[i] & 255;
            }

            return count;
        }
    }

    public static class PKCS7Padding {
        private static final Charset CHARSET = Charset.forName("utf-8");
        private static final int BLOCK_SIZE = 32;

        public PKCS7Padding() {
        }

        public static byte[] getPaddingBytes(int count) {
            int amountToPad = 32 - count % 32;
            if (amountToPad == 0) {
                amountToPad = 32;
            }

            char padChr = chr(amountToPad);
            String tmp = new String();

            for (int index = 0; index < amountToPad; ++index) {
                tmp = tmp + padChr;
            }

            return tmp.getBytes(CHARSET);
        }

        public static byte[] removePaddingBytes(byte[] decrypted) {
            int pad = decrypted[decrypted.length - 1];
            if (pad < 1 || pad > 32) {
                pad = 0;
            }

            return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
        }

        private static char chr(int a) {
            byte target = (byte)(a & 255);
            return (char)target;
        }
    }

    public static class DingTalkEncryptException extends Exception {
        public static final int SUCCESS = 0;
        public static final int ENCRYPTION_PLAINTEXT_ILLEGAL = 900001;
        public static final int ENCRYPTION_TIMESTAMP_ILLEGAL = 900002;
        public static final int ENCRYPTION_NONCE_ILLEGAL = 900003;
        public static final int AES_KEY_ILLEGAL = 900004;
        public static final int SIGNATURE_NOT_MATCH = 900005;
        public static final int COMPUTE_SIGNATURE_ERROR = 900006;
        public static final int COMPUTE_ENCRYPT_TEXT_ERROR = 900007;
        public static final int COMPUTE_DECRYPT_TEXT_ERROR = 900008;
        public static final int COMPUTE_DECRYPT_TEXT_LENGTH_ERROR = 900009;
        public static final int COMPUTE_DECRYPT_TEXT_CORPID_ERROR = 900010;
        private static Map<Integer, String> msgMap = new HashMap();
        private Integer code;

        static {
            msgMap.put(0, "成功");
            msgMap.put(900001, "加密明文文本非法");
            msgMap.put(900002, "加密时间戳参数非法");
            msgMap.put(900003, "加密随机字符串参数非法");
            msgMap.put(900005, "签名不匹配");
            msgMap.put(900006, "签名计算失败");
            msgMap.put(900004, "不合法的aes key");
            msgMap.put(900007, "计算加密文字错误");
            msgMap.put(900008, "计算解密文字错误");
            msgMap.put(900009, "计算解密文字长度不匹配");
            msgMap.put(900010, "计算解密文字corpid不匹配");
        }

        public Integer getCode() {
            return this.code;
        }

        public DingTalkEncryptException(Integer exceptionCode) {
            super((String)msgMap.get(exceptionCode));
            this.code = exceptionCode;
        }
    }
    static {
                try {
                        Security.setProperty("crypto.policy", "limited");
                        RemoveCryptographyRestrictions();
                } catch (Exception var1) {
                }

        }
        private static void RemoveCryptographyRestrictions() throws Exception {
                Class<?> jceSecurity = getClazz("javax.crypto.JceSecurity");
                Class<?> cryptoPermissions = getClazz("javax.crypto.CryptoPermissions");
                Class<?> cryptoAllPermission = getClazz("javax.crypto.CryptoAllPermission");
                if (jceSecurity != null) {
                        setFinalStaticValue(jceSecurity, "isRestricted", false);
                        PermissionCollection defaultPolicy = (PermissionCollection)getFieldValue(jceSecurity, "defaultPolicy", (Object)null, PermissionCollection.class);
                        if (cryptoPermissions != null) {
                                Map<?, ?> map = (Map)getFieldValue(cryptoPermissions, "perms", defaultPolicy, Map.class);
                                map.clear();
                        }

                        if (cryptoAllPermission != null) {
                                Permission permission = (Permission)getFieldValue(cryptoAllPermission, "INSTANCE", (Object)null, Permission.class);
                                defaultPolicy.add(permission);
                        }
                }

        }
        private static Class<?> getClazz(String className) {
                Class clazz = null;

                try {
                        clazz = Class.forName(className);
                } catch (Exception var3) {
                }

                return clazz;
        }
        private static void setFinalStaticValue(Class<?> srcClazz, String fieldName, Object newValue) throws Exception {
                Field field = srcClazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                Field modifiersField = Field.class.getDeclaredField("modifiers");
                modifiersField.setAccessible(true);
                modifiersField.setInt(field, field.getModifiers() & -17);
                field.set((Object)null, newValue);
        }
        private static <T> T getFieldValue(Class<?> srcClazz, String fieldName, Object owner, Class<T> dstClazz) throws Exception {
                Field field = srcClazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                return dstClazz.cast(field.get(owner));
        }

}
View Code

事件订阅页面

当我们配置没问题后,将会显式事件订阅页面,我们可以选择订阅的事件。这里我们只选择了“群内安装酷应用”和“群内卸载酷应用”

 

 

7.4 机器人

 

7.5 互动卡片

钉钉发送互动卡片的api接口个人感觉有点乱,这里做一下整理

发送互动卡片的接口有如下几个

  1. https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend
  2. https://api.dingtalk.com/v1.0/im/v1.0/robot/interactiveCards/send
  3. https://api.dingtalk.com/v1.0/im/interactiveCards/send
  4. https://api.dingtalk.com/v1.0/im/interactiveCards/templates/send

下面逐一进行介绍

7.5.1 机器人批量发送消息batchSend

接口为:https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend

官网文档:https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches

该接口适用于,使用机器人批量向若干人发送消息。

可以发送纯文本、markdown、ImageMsg、LinkMsg、消息卡片

示例

 

 

结果展示

 

 

7.5.2 机器人发送互动卡片(普通版)

接口为:https://api.dingtalk.com/v1.0/im/v1.0/robot/interactiveCards/send

官网:https://open.dingtalk.com/document/orgapp/robots-send-interactive-cards

这个接口,我们使用钉钉内置的卡片模板:StandardCard,无需我们创建卡片模板

请求参数:

{
    "cardTemplateId": "StandardCard",
    "singleChatReceiver": "{\"userId\":\"manager3869\"}",
    "cardBizId": "msgcardid005",
    "robotCode": "dingctcvj5ermepmlw9j",
    "cardData" : "{\"config\":{\"autoLayout\":true,\"enableForward\":true},\"header\":{\"title\":{\"type\":\"text\",\"text\":\"订餐\"},\"logo\":\"@lALPDrz7jNRJdJE4OA\"},\"contents\":[{\"type\":\"image\",\"image\":\"@lALPDfYH0aWc_a3NAljNAyA\",\"ratio\":\"16:9\",\"id\":\"image_1678176671869\"},{\"type\":\"markdown\",\"text\":\"套餐内容:*西兰花、胡萝卜、鸡蛋、荞麦面、玉米、莴笋、紫薯*\",\"id\":\"markdown_1678176671869\"},{\"type\":\"section\",\"content\":{\"type\":\"text\",\"text\":\"主菜选择:\",\"id\":\"text_1678176671869\"},\"extra\":{\"type\":\"select\",\"options\":[{\"label\":{\"type\":\"text\",\"text\":\"🐔 鸡肉\",\"id\":\"text_1678176671937\"},\"value\":\"1\"},{\"label\":{\"type\":\"text\",\"text\":\"🥩 牛肉\",\"id\":\"text_1678176671871\"},\"value\":\"2\"}],\"placeholder\":{\"type\":\"text\",\"text\":\"请选择\",\"id\":\"text_1678176671955\"},\"id\":\"select_1678176671869\"},\"id\":\"section_1678176671869\"},{\"type\":\"section\",\"content\":{\"type\":\"text\",\"text\":\"取餐地点:\",\"id\":\"text_1678176671881\"},\"extra\":{\"type\":\"select\",\"options\":[{\"label\":{\"type\":\"text\",\"text\":\"5号楼取餐点\",\"id\":\"text_1678176671939\"},\"value\":\"1\"},{\"label\":{\"type\":\"text\",\"text\":\"10号楼取餐点\",\"id\":\"text_1678176671918\"},\"value\":\"2\"},{\"label\":{\"type\":\"text\",\"text\":\"餐厅服务台\",\"id\":\"text_1678176671908\"},\"value\":\"3\"}],\"placeholder\":{\"type\":\"text\",\"text\":\"请选择\",\"id\":\"text_1678176671928\"},\"id\":\"select_1678176671870\"},\"id\":\"section_1678176671932\"},{\"type\":\"action\",\"actions\":[{\"type\":\"button\",\"label\":{\"type\":\"text\",\"text\":\"一键预定\",\"id\":\"text_1678176671922\"},\"actionType\":\"request\",\"status\":\"primary\",\"id\":\"button_1678176671869\"}],\"id\":\"action_1678176671869\"},{\"type\":\"divider\",\"id\":\"divider_1678176671869\"},{\"type\":\"markdown\",\"text\":\"**3月15日健康餐已预定:24/40 份**\",\"id\":\"markdown_1678176671942\"},{\"type\":\"markdown\",\"text\":\"<font color=common_level3_base_color>赫莎莎 牛肉套餐</font>\",\"icon\":\"@lALPDsCJC3kB4IYwMA\",\"id\":\"markdown_1678176671953\"},{\"type\":\"markdown\",\"text\":\"<font color=common_level3_base_color>周小丽 牛肉套餐</font>\",\"icon\":\"@lALPDsekCMKd0tMwMA\",\"id\":\"markdown_1678176671925\"},{\"type\":\"markdown\",\"text\":\"<font color=common_level3_base_color>黄敏敏 牛肉套餐</font>\",\"icon\":\"@lALPEBkmB-g2_NIwMA\",\"id\":\"markdown_1678176671995\"},{\"type\":\"action\",\"actions\":[{\"type\":\"button\",\"label\":{\"type\":\"text\",\"text\":\"查看全部\",\"id\":\"text_1678176671919\"},\"actionType\":\"openLink\",\"url\":{\"all\":\"https://www.dingtalk.com\"},\"status\":\"normal\",\"id\":\"button_1678176671885\"}],\"id\":\"action_1678176671902\"}]}"
}

需要注意的是 cardBizId 必须每次不一样,不然会重复发送之前已经发送过的卡片(钉钉中使用这个字段唯一标识一个卡片)

而且,cardData需要在钉钉卡片搭建平台进行搭建:https://card.dingtalk.com/card-builder

效果

7.5.3 发送互动卡片(使用模板)

接口为:https://api.dingtalk.com/v1.0/im/interactiveCards/send

官网:https://open.dingtalk.com/document/orgapp/send-interactive-dynamic-cards-1

这个接口用于使用我们创建的模板发送互动卡片,我们需要首先创建卡片模板

比如:

 

 

 这里的参数title需要我们传入替换。具体示例如下

请求参数:

{
  "cardTemplateId" : "69f9d1fe-f080-4c72-919d-ac43c4eb0708",
  "openConversationId" : null,
  "outTrackId":"003",
  "conversationType":0,
  "receiverUserIdList" : ["manager3869"],
  "cardData" : {
    "cardParamMap" : {
      "title" : "巴拉巴拉啦啦啦"
    }
  }
}

需要注意的是outTrackId需要每次都不一样,用于唯一标识一个卡片。而且这里不需要robotId(因为我们的卡片模板是基于钉钉微应用,而微应用中已经配置了机器人)。

 

 

7.5.4 发送轻量级卡片

接口为:https://api.dingtalk.com/v1.0/im/interactiveCards/templates/send

官网:https://open.dingtalk.com/document/orgapp/send-lightweight-interactive-cards

在这种模式中,钉钉为我们提供了几个内置模板:

  1. TuWenCard01
  2. TuWenCard02
  3. TuWenCard03
  4. TuWenCard04

请求参数:

{
  "cardTemplateId" : "TuWenCard01",
  "openConversationId" : null,
  "callbackUrl": "http://xxx.vaiwan.cn/biz_callback",
  "outTrackId":"msgcardid002",
  "singleChatReceiver" : "{\"userId\":\"manager3869\"}","robotCode" : "dingctcvj5ermepmlw9j",
  "tokenGrantType": 0,
  "cardData" : "{\"header\":{\"icon\":{\"light\":\"https://xxxx.png\",\"dark\":\"https://xxx.png\"},\"text\":{\"zh_Hans\":\"公告:测试TuWenCard01\"},\"color\":{\"light\":\"#00B853\",\"dark\":\"#00B853\"}},\"contents\":[{\"text\":{\"zh_Hans\":\"大家按照这个格式填写下,每周我会做一个统计和公布哈,和大家同步下我们的进展\"},\"type\":\"PARAGRAPH\",\"icon\":{\"light\":\"https://xxx.png\",\"dark\":\"https://xxx.png\"}},{\"text\":{\"zh_Hans\":\"text2\"},\"type\":\"TITLE\"},{\"text\":{\"zh_Hans\":\"大家按照这个格式填写下,每周我会做一个统计和公布哈,和大家同步下我们的进展\"},\"type\":\"DESCRIPTION\"},{\"type\":\"IMAGE\",\"image\":\"@lALPDeREVttTpCrNA6rNA6o\"},{\"type\":\"MARKDOWN\",\"markdown\":\"#测试无序列表\n*✅预览区域代码高亮\n*✅所有选项自动记忆\n开始**加粗**结束\n开始*斜体*结束\n开始***加粗与斜体***结束\n<fontcolor=#00B042size=15>测试:【正向文字:用于表达上涨上升、正向反馈文字,禁止大面积使用。】【15号字体】**【加粗】**</font>\n<fontcolor=#FF5219size=12>测试:【报错:用户内容报错、警示内容,禁止大面积使用。】【12号字体】*【斜体】*</font>\"}],\"actions\":[{\"id\":\"1\",\"text\":{\"zh_Hans\":\"钉钉网站\"},\"icon\":{\"light\":\"@lALPDeREVttTpCrNA6rNA6o\"},\"status\":\"NORMAL\",\"actionType\":\"URL\",\"actionUrl\":{\"android\":\"https://developers.dingtalk.com\",\"ios\":\"https://developers.dingtalk.com\",\"pc\":\"https://developers.dingtalk.com\"}}],\"actionDirection\":\"HORIZONTAL\"}"
}

同样注意的是outTrackId必须唯一

结果:

 

posted @ 2022-11-16 16:32  zhenjingcool  阅读(7259)  评论(0编辑  收藏  举报