钉钉H5微应用开发指南
我们在这一片文章https://www.cnblogs.com/zhenjingcool/p/16896198.html中对钉钉开放平台进行了简略介绍,钉钉开放平台为我们提供了5种开放能力,即应用开发、工作台开放、群开放、连接平台、智能硬件接入。
这里我们详细介绍这5部分中的其中之一:应用开发。而且是应用开发中的H5微应用。
1 总概
在使用钉钉开放平台的能力开发应用前,请注意:
-
调用钉钉服务端接口时,需使用HTTPS协议、JSON数据格式、UTF-8编码,POST请求请在HTTP Header中设置 Content-Type:application/json。
访问域名为:
-
新版服务端接口:
https://api.dingtalk.com
。 -
旧版服务端接口:
https://oapi.dingtalk.com
。
-
-
在调用服务端接口时,有调用频率限制。比如每个IP允许调用钉钉提供的接口6000次/20秒、机器人发送信息限制为20条/分钟等等。
-
在调用服务端接口时,需要提前设置了对应的接口权限
-
必须接入钉钉免登,即在用户打开应用时可直接获取用户身份无需输入钉钉账号和密码。
-
H5微应用需要进行JSAPI鉴权。详情请参考https://open.dingtalk.com/document/orgapp-client/jsapi-authentication
-
推荐配置事件订阅。钉钉会向应用推送订阅的事件,例如部门变更、签到通知、打卡通知等。通过订阅这些事件,开发者可以更好地与钉钉集成。
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微应用、工作台组件,项目目录下会包含一个 |
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)); } }
事件订阅页面
当我们配置没问题后,将会显式事件订阅页面,我们可以选择订阅的事件。这里我们只选择了“群内安装酷应用”和“群内卸载酷应用”
7.4 机器人
7.5 互动卡片
钉钉发送互动卡片的api接口个人感觉有点乱,这里做一下整理
发送互动卡片的接口有如下几个
- https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend
- https://api.dingtalk.com/v1.0/im/v1.0/robot/interactiveCards/send
- https://api.dingtalk.com/v1.0/im/interactiveCards/send
- 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
在这种模式中,钉钉为我们提供了几个内置模板:
- TuWenCard01
- TuWenCard02
- TuWenCard03
- 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必须唯一
结果: