美文网首页
钉钉的第三方应用开发对接

钉钉的第三方应用开发对接

作者: 天草二十六_简村人 | 来源:发表于2022-09-01 23:14 被阅读0次

一、基础概念

注意:时序图中的步骤分为多个,特别是“调用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

区分的维度是suiteId,为什么不是appId呢?这里是看钉钉回调业务的消息体内容。对应上文中的“事件与回调”。缓存周期建议比suiteTicket的有效期要长一些。

image.png

下面给出解密后的报文示例:

{
    "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获取用户信息

个人觉得不必要对authCode进行缓存,前端每次拉起的时候authCode都会变化,也就是官网说的只能使用一次。

image.png
/**
     * 获取用户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);
        }
    }

七、钉钉模板消息

基于应用内的消息推送,先在应用中新建消息模板,再然后在程序中使用。需要注意的是它依赖“5.4、agentId”中的值。

模板编号.png
/**
     * 发送模板消息.
     * <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);
        }
    }

相关文章

网友评论

      本文标题:钉钉的第三方应用开发对接

      本文链接:https://www.haomeiwen.com/subject/abplnrtx.html