我们在这一片文章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必须唯一
结果: