一、基础概念
-
https://open.dingtalk.com/document/isvapp-server/edu-overview
-
钉钉官方的流程图
在线课堂.png
注意:时序图中的步骤分为多个,特别是“调用jsapi唤起在线课堂”,之后才能够调用“加入课堂”接口,将加入课堂的链接地址主动推送给用户。
-
应用部署图
image.png -
整体流程图
image.png后端封装了一个“快速授课”接口,在选择了学生列表后,添加课程参与方和加入课程的人都是已选的学生列表,不用像官方那样进行两次的选人。
二、调试中遇到的问题
-
提示:"errcode":40079,"errmsg":"不存在的授权信息"
对组织进行授权.png -
加入课程成功,但是用户收不到提醒消息。
{
"body":"{\"errcode\":0,\"errmsg\":\"ok\",\"result\":{\"joinable\":false},\"success\":true,\"request_id\":\"15rr3j8qmkdah\"}",
"errcode":0,
"errmsg":"ok",
"errorCode":"0",
"msg":"ok",
"params":{
"course_code":"evvgu33643006",
"op_user_id":"022029551025762327"
},
"result":{
"joinable":false
},
"subCode":"",
"subMsg":"",
"success":true
}
问题原因是“开始课程”后,没有“jsapi唤起在线课程”。
三、事件与回调
配置回调.png回调的地址由对接方来提供,必须是外网可访问的地址。先在开发管理中,添加外网IP列表。
配置外网IP地址白名单.png
四、监控中心
在应用自检中,会有要求你的服务接入监控中心。
- 是否已设置服务器出口IP
- 应用首页或管理后台地址是否https协议
- 是否已设置有效推送类型
- 是否已完成监控中心接入
- 服务端是否已部署在阿里云
<meta name="wpk-bid" content="dta_2_118502"> <script>var isDingtalk = navigator && /DingTalk/.test(navigator.userAgent);var isProductEnv = window &&window.location &&window.location.host
&& window.location.host.indexOf('//127.0.0.1')===-1
&& window.location.host.indexOf('//localhost')===-1
&& window.location.host.indexOf('//192.168.')===-1
// 如果有其它测试域名,请一起排掉,减少测试环境对生产环境监控的干扰
if (isProductEnv) { !(function(c,i,e,b){var h=i.createElement("script");var f=i.getElementsByTagName("script")[0];h.type="text/javascript";h.crossorigin=true;h.onload=function(){c[b]||(c[b]=new c.wpkReporter({bid:"dta_2_118502"}));c[b].installAll()};f.parentNode.insertBefore(h,f);h.src=e})(window,document,"https://g.alicdn.com/woodpeckerx/jssdk??wpkReporter.js","__wpk"); }</script>
五、需要缓存的值
5.1、suiteTicket
image.png区分的维度是suiteId,为什么不是appId呢?这里是看钉钉回调业务的消息体内容。对应上文中的“事件与回调”。缓存周期建议比suiteTicket的有效期要长一些。
下面给出解密后的报文示例:
{
"EventType":"SYNC_HTTP_PUSH_HIGH",
"bizData":[
{
"gmt_create":1661804910000,
"biz_type":2,
"open_cursor":0,
"subscribe_id":"27321004_0",
"id":23329,
"gmt_modified":1661804910000,
"biz_id":"27321004",
"biz_data":{
"syncAction":"suite_ticket",
"suiteTicket":"2pK7bMRBxWqpgDq7bY42BAGwLcyipn0Agl8mu3GzG85dK1N3AH0rJYb1411AOIWrfi5c1E0ItfTbnBZ4Eglgf0",
"syncSeq":"8F9B5E2285AB8CDEE8CA8D33A9"
},
"corp_id":"ding666889832d57ffd735c2f4657eb6378f",
"status":0
}
]
}
代码示例:
@PostMapping("/api/v1/ding/notify")
public ResponseEntity<?> notify(HttpServletRequest request) throws Exception {
String requestJson = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
if (log.isInfoEnabled()) {
log.info("钉钉回调的完整信息是{}", JSON.toJSON(requestJson));
}
// 1. 从http请求中获取加解密参数
String msg_signature = request.getParameter("msg_signature");
if (msg_signature == null) {
msg_signature = request.getParameter("signature");
}
String timeStamp = request.getParameter("timeStamp");
if (timeStamp == null) {
timeStamp = request.getParameter("timestamp");
}
String nonce = request.getParameter("nonce");
JSONObject bodyJson = JSON.parseObject(requestJson);
String encrypt = bodyJson.getString("encrypt");
// 2. 使用加解密类型
DingCallbackCrypto callbackCrypto = new DingCallbackCrypto(dingTalkConfig.getCallBackToken(),
dingTalkConfig.getCallBackAesKey(),
dingTalkConfig.getSuiteKey());
final String decryptMsg = callbackCrypto.getDecryptMsg(msg_signature, timeStamp, nonce, encrypt);
if (log.isInfoEnabled()) {
log.info("钉钉回调正文(解密后):{}", decryptMsg);
}
// 3. 处理回调
dingTalkAppService.refreshSuiteTicket(decryptMsg);
// 5. 返回success的加密数据
Map<String, String> successMap = callbackCrypto.getEncryptedMap("success");
return ResponseEntity.ok(successMap);
}
解析回调正文,有了上面的示例报文后,代码实现就相对简单了。建议最后将suiteTicket保存到redis中。
public void refreshSuiteTicket(String decryptMsg) {
JSONObject eventJson = JSON.parseObject(decryptMsg);
if (!Constants.SYNC_HTTP_PUSH_HIGH.equals(eventJson.getString(EVENT_TYPE_KEY))) {
return;
}
JSONArray bizDataList = eventJson.getJSONArray(Constants.BIZ_DATA_KEY);
Precondition.notEmpty(bizDataList, "钉钉推送的数据内容为空");
for (Iterator<Object> iterator = bizDataList.iterator(); iterator.hasNext(); ) {
JSONObject jsonObject = (JSONObject) iterator.next();
if (Constants.BIZ_TYPE_2 != jsonObject.getInteger(Constants.BizDataKeyConstant.BIZ_TYPE_KEY)) {
continue;
}
String bizDataStr = jsonObject.getString(Constants.BizDataKeyConstant.BIZ_DATA_KEY);
String bizId = jsonObject.getString(Constants.BizDataKeyConstant.BIZ_ID_KEY);
BizDataDTO bizDataDTO = JSONObject.parseObject(bizDataStr, BizDataDTO.class);
if (null == bizDataDTO) {
continue;
}
if (Constants.BizDataKeyConstant.SUITE_TICKET.equals(bizDataDTO.getSyncAction())) {
// 刷新缓存中的suiteTicket值
suiteTicketService.refresh(bizId, bizDataDTO.getSuiteTicket());
}
}
}
5.2、accessToken
钉钉官方文档也写得很清楚,建议你不要频繁地获取accessToken,那我们就乖乖地缓存起来吧。在没有缓存的情况下,才去重新获取。这里就涉及到一个缓存的有效期设置多久呢?建议比expiresIn的周期短几分钟即可。
这里依赖上一步的suiteTicket值,还有就是suiteKey和suiteSecret。
suiteKey和suiteSecret是在应用信息中找到.png
这里就不建议你命名为appKey和appSecret了。
/**
* 获取accessToken.
* <p>
* 文档地址:https://open.dingtalk.com/document/orgapp-server/obtains-the-enterprise-authorized-credential
* </p>
*
* @return
*/
public String getAccessToken(String appId) {
// 1.从缓存中获取
String accessToken = accessTokenService.get(appId);
if (StringUtils.isNotEmpty(accessToken)) {
return accessToken;
}
//2.如果不存在,则刷新
try {
DingTalkClient client = new DefaultDingTalkClient(BASE_URL + "/service/get_corp_token");
OapiServiceGetCorpTokenRequest req = new OapiServiceGetCorpTokenRequest();
req.setAuthCorpid(dingTalkConfig.getCorpId());
String suiteTicket = suiteTicketService.get(dingTalkConfig.getSuiteId());
if (StringUtils.isEmpty(suiteTicket)) {
log.error("suiteTicket为空, suiteId={}", dingTalkConfig.getSuiteId());
}
Precondition.notBlank(suiteTicket, "suiteTicket不能为空, suiteId=%s", dingTalkConfig.getSuiteId());
OapiServiceGetCorpTokenResponse response = client.execute(req,
dingTalkConfig.getSuiteKey(),
dingTalkConfig.getSuiteSecret(),
suiteTicket);
if (!response.isSuccess()) {
log.warn("钉钉--获取accessToken出现错误, " +
"[errcode={}, errmsg={}, corpId={}, suiteKey={}, suiteSecret={}, suiteTicket={}]",
response.getErrcode(), response.getErrmsg(), dingTalkConfig.getCorpId(),
dingTalkConfig.getSuiteKey(), dingTalkConfig.getSuiteSecret(), suiteTicket);
Precondition.isTrue(false, "钉钉--获取accessToken出现错误, errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
// 把accessToken保存到缓存中,区分的维度是appId,缓存有效期随着expiresIn值
accessTokenService.refresh(appId, response.getAccessToken(), response.getExpiresIn());
return response.getAccessToken();
} catch (ApiException e) {
log.warn("钉钉--获取accessToken出现异常", e);
throw new IllegalArgumentException("钉钉--调用获取accessToken接口出现异常", e);
}
}
5.3、jsapiTicket
h5前端页面需要根据它来拉起钉钉的Js组件。
/**
* 获取jsapi_ticke.
* <p>
* 文档地址:https://open.dingtalk.com/document/isvapp-server/obtain-jsapi_ticket
* </p>
*
* @param appId
* @return
*/
public String getJsapiTicket(String appId) {
// 1.从缓存中获取
String jsapiTicket = jsapiTicketService.get(appId);
if (StringUtils.isNotEmpty(jsapiTicket)) {
return jsapiTicket;
}
//2.如果不存在,则刷新
try {
DingTalkClient client = new DefaultDingTalkClient(BASE_URL + "/get_jsapi_ticket");
OapiGetJsapiTicketRequest req = new OapiGetJsapiTicketRequest();
OapiGetJsapiTicketResponse response = client.execute(req,
this.getAccessToken(appId));
if (!response.isSuccess()) {
log.warn("钉钉--获取jsapi_ticket出现错误, [errcode={}, errmsg={}]",
response.getErrcode(), response.getErrmsg());
Precondition.isTrue(false, "钉钉--获取jsapi_ticket出现错误,errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
// 把jsapiTicket保存到缓存中(类同于accessToken)
jsapiTicketService.refresh(appId, response.getTicket(), response.getExpiresIn());
return response.getTicket();
} catch (ApiException e) {
log.warn("钉钉--获取jsapi_ticket出现异常", e);
throw new IllegalArgumentException("钉钉--调用获取jsapi_ticket接口出现异常", e);
}
}
5.4、agentId
获取企业授权信息,在调用发送钉钉模板消息的接口,需要传入agentId字段。
/**
* 获取企业授权信息.
* <p>
* 文档地址:https://open.dingtalk.com/document/isvapp-server/obtains-the-basic-information-of-an-enterprise
* </p>
*
* @param appId
* @return
*/
public Long getAgentId(String appId) {
// 1.从缓存中获取
String agentId = agentIdService.get(appId);
if (StringUtils.isNotEmpty(agentId)) {
return Long.parseLong(agentId);
}
//2.如果不存在,则刷新
try {
DingTalkClient client = new DefaultDingTalkClient(BASE_URL + "/service/get_auth_info");
OapiServiceGetAuthInfoRequest req = new OapiServiceGetAuthInfoRequest();
req.setSuiteKey(dingTalkConfig.getSuiteKey());
req.setAuthCorpid(dingTalkConfig.getCorpId());
String suiteTicket = suiteTicketService.get(dingTalkConfig.getSuiteId());
if (StringUtils.isEmpty(suiteTicket)) {
log.error("suiteTicket为空, suiteId={}", dingTalkConfig.getSuiteId());
}
Precondition.notBlank(suiteTicket, "suiteTicket不能为空, suiteId=%s", dingTalkConfig.getSuiteId());
OapiServiceGetAuthInfoResponse response = client.execute(req,
dingTalkConfig.getSuiteKey(),
dingTalkConfig.getSuiteSecret(),
suiteTicket);
if (!response.isSuccess()) {
log.warn("钉钉--获取企业授权信息出现错误, [errcode={}, errmsg={}, request={}]",
response.getErrcode(), response.getErrmsg(), JSON.toJSONString(req));
Precondition.isTrue(false, "钉钉--获取企业授权信息出现错误,errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
if (null == response.getAuthInfo() || CollectionUtils.isEmpty(response.getAuthInfo().getAgent())) {
Precondition.isTrue(false, "钉钉--获取企业授权信息为空,errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
Long iAgentId = response.getAuthInfo().getAgent().get(0).getAgentid();
agentIdService.refresh(appId, String.valueOf(iAgentId));
return iAgentId;
} catch (ApiException e) {
log.warn("钉钉--获取企业授权信息出现异常", e);
throw new IllegalArgumentException("钉钉--调用获取企业授权信息接口出现异常", e);
}
}
六、前端根据authCode获取用户信息
image.png个人觉得不必要对authCode进行缓存,前端每次拉起的时候authCode都会变化,也就是官网说的只能使用一次。
/**
* 获取用户ID.
* <p>
* 文档地址:https://open.dingtalk.com/document/orgapp-server/obtain-the-userid-of-a-user-by-using-the-log-free
* </p>
*
* @param authCode
* @return
*/
public OapiV2UserGetuserinfoResponse.UserGetByCodeResponse getUserId(String appId, String authCode) {
try {
// 获取用户信息
DingTalkClient client = new DefaultDingTalkClient(BASE_URL + "/topapi/v2/user/getuserinfo");
OapiV2UserGetuserinfoRequest req = new OapiV2UserGetuserinfoRequest();
req.setCode(authCode);
OapiV2UserGetuserinfoResponse response = client.execute(req, this.getAccessToken(appId));
if (!response.isSuccess()) {
log.warn("钉钉--获取用户信息出现错误, [errcode={}, errmsg={}, authCode={}]",
response.getErrcode(), response.getErrmsg(), authCode);
Precondition.isTrue(false, "钉钉--获取用户信息出现错误, errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
OapiV2UserGetuserinfoResponse.UserGetByCodeResponse userInfo = response.getResult();
if (null == userInfo) {
log.warn("钉钉--获取用户信息为空, [errcode={}, errmsg={}, authCode={}]",
response.getErrcode(), response.getErrmsg(), authCode);
return null;
}
return userInfo;
} catch (ApiException e) {
log.warn("钉钉--获取用户信息出现异常", e);
throw new IllegalArgumentException("钉钉--调用获取用户信息接口出现异常", e);
}
}
七、钉钉模板消息
模板编号.png基于应用内的消息推送,先在应用中新建消息模板,再然后在程序中使用。需要注意的是它依赖“5.4、agentId”中的值。
/**
* 发送模板消息.
* <p>
* 文档地址:https://open.dingtalk.com/document/isvapp-server/work-notification-templating-send-notification-interface
* </p>
*
* @return
*/
public String sendTemplateMessage(String appId, String userId, String userName, String link, String className) {
if (log.isInfoEnabled()) {
log.info("钉钉--发送模版消息, appId={}, userId={}, userName={}, link={}, className={}",
appId, userId, userName, link, className);
}
try {
DingTalkClient client = new DefaultDingTalkClient(BASE_URL + "/topapi/message/corpconversation/sendbytemplate");
OapiMessageCorpconversationSendbytemplateRequest req = new OapiMessageCorpconversationSendbytemplateRequest();
// 获取agentId
req.setAgentId(this.getAgentId(appId));
// 用户列表
req.setUseridList(userId);
// 模板ID
req.setTemplateId(DING_MSG_TEMPLATE_ID);
// 消息模板中的key-value
JSONObject data = new JSONObject();
data.put("link", link);
data.put("name", userName);
data.put("className", className);
data.put("startTime", "2022-09-01");
data.put("endTime", "2022-10-16");
req.setData(data.toJSONString());
OapiMessageCorpconversationSendbytemplateResponse response = client.execute(req, this.getAccessToken(appId + ""));
if (!response.isSuccess()) {
log.warn("钉钉--发送模版消息出现错误, [errcode={}, errmsg={}, request={}]",
response.getErrcode(), response.getErrmsg(), JSON.toJSONString(req));
Precondition.isTrue(false, "钉钉--发送模版消息出现错误,errCode=%s, errMsg=%s",
response.getErrcode(), response.getErrmsg());
}
log.info("钉钉--发送模版消息成功,返回值是{}", JSON.toJSONString(response));
return response.getRequestId();
} catch (ApiException e) {
log.warn("钉钉--发送模版消息出现异常", e);
throw new IllegalArgumentException("钉钉--调用发送模版消息接口出现异常", e);
}
}
网友评论