钉钉OA自定义审批流的创建和使用

前言

大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

钉钉作为一款办公软件,审批功能是它的核心功能之一,最常见的审批场景就是请假和报销了。虽然钉钉也内置了一些审批流,但是审批场景层出不穷,光靠钉钉内置的那些是不够用的。尤其一些公司自己也有技术团队,则更希望可以二次开发一下,做一套更适合自己公司的审批流。那么本文我们就钉钉的审批能力来讲一下:钉钉OA自定义审批流的创建和使用。

tips:钉钉OA审批在哪里

这个还是要说下,否则很多人都找不到!

1. 扫码登录钉钉OA

登录链接如下:https://oa.dingtalk.com/

2. 工作台-应用管理-OA审批-进入

进去之后是这样的,我们也可以在这里创建新表单,不过这里创建的表单是不支持代码调用的。

3. 如果实在找不到,去搜索框中搜索审批

那么,接下来正文开始!

一、创建小程序

如果你的组织的类型是认证服务商,那么可以选择创建第三方企业应用,否则就创建企业内部应用。

这两种应用的主要区别就是获取AccessToken的方式不同,如何不同可以看我的这篇文章:钉钉小程序生态1—区分企业内部应用、第三方企业应用、第三方个人应用

那么如何判断自己是不是服务商组织呢?登录开放平台—>首页—>有认证服务商标签的就是啦

这里我为了方便文章撰写,我就创建一个企业内部应用来说明接下来的流程。如果大家使用的是第三方企业应用,那么还需要配置一下钉钉事件回调,详细可见我这篇文章:
钉钉小程序生态4—钉钉应用事件与回调

1. 应用开发-企业内部应用-创建应用-H5微应用

这里H5微应用、小程序两种类型都可以,我们主要是为了获取创建钉钉OA自定义审批流的权限。

2. 基础信息—权限管理—搜索审批

权限一共5个全都点申请,将对应权限权限申请好之后,我们就可以调用接口创建OA审批模板和发起审批实例了。

3. 应用功能—事件与回调—事件订阅—开启审批事件回调

如何接入可以看钉钉的官方文档:配置Stream推送,非常的简单,这里我就不贴代码了。
配置回调的作用是为了后续审批状态发生变化的时候可以及时通知到我们。

到目前为止,创建和配置相关的工作我们已经完成了,接下来就是开发了。

二、创建或更新审批表单模板

模板的创建是一次性的,也就是说只需要调用一下创建接口就行,这里复杂的东西是它的控件很多,比如:文本框、数字框、日期选择器等等,如下图:

用可视化界面创建固然是容易,但是要用代码来创建就有点麻烦了,我开始也错了好几次,从简单的控件开始尝试就好了,多试几次就行。
官方链接如下:创建或更新审批表单模板
这里我自己创建的代码如下:

package com.example.dingtalkoa.demo;

import com.aliyun.dingtalkworkflow_1_0.models.FormComponent;
import com.aliyun.dingtalkworkflow_1_0.models.FormComponentProps;
import com.aliyun.dingtalkworkflow_1_0.models.FormCreateHeaders;
import com.aliyun.dingtalkworkflow_1_0.models.FormCreateRequest;
import com.aliyun.dingtalkworkflow_1_0.models.FormCreateResponse;
import com.aliyun.tea.TeaException;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.Common;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Sample3 {

    /**
     * 获取AccessToken
     *
     * @return
     */
    public static String getAccessToken() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        com.aliyun.dingtalkoauth2_1_0.Client client = new com.aliyun.dingtalkoauth2_1_0.Client(config);
        com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest getAccessTokenRequest
            = new com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest()
            .setAppKey("xxx")
            .setAppSecret("xxxx");
        try {
            return client.getAccessToken(getAccessTokenRequest).getBody().getAccessToken();
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        }
        return null;
    }

    public static void main(String[] args) throws Exception {
        Config config = new Config();
        config.protocol = "https";
        config.regionId = "central";
        com.aliyun.dingtalkworkflow_1_0.Client client = new com.aliyun.dingtalkworkflow_1_0.Client(config);
        FormCreateHeaders formCreateHeaders = new FormCreateHeaders();
        formCreateHeaders.xAcsDingtalkAccessToken = getAccessToken();
        // 1. 单行输入控件
        FormComponentProps formComponentProps1 = new FormComponentProps()
            .setComponentId("TextField-title")
            .setPlaceholder("文章标题")
            .setLabel("文章标题")
            .setRequired(true);
        FormComponent formComponent1 = new FormComponent()
            .setComponentType("TextField")
            .setProps(formComponentProps1);
        FormComponentProps formComponentProps2 = new FormComponentProps()
            .setComponentId("TextField-url")
            .setPlaceholder("文章内容链接")
            .setLabel("文章内容链接")
            .setRequired(true);
        FormComponent formComponent2 = new FormComponent()
            .setComponentType("TextField")
            .setProps(formComponentProps2);

        FormCreateRequest formCreateRequest = new FormCreateRequest()
            .setName("文章发布申请")
            .setDescription("文章发布申请")
            .setFormComponents(java.util.Arrays.asList(formComponent1, formComponent2));
        try {
            FormCreateResponse formCreateResponse = client.formCreateWithOptions(formCreateRequest, formCreateHeaders,
                new RuntimeOptions());
            System.out.println("创建的processCode:" + formCreateResponse.getBody().getResult().getProcessCode());
        } catch (TeaException err) {
            log.error("--->", err);
            if (!Common.empty(err.code) && !Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        } catch (Exception _err) {
            log.error("--->", _err);
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!Common.empty(err.code) && !Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        }
    }

}

maven依赖代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.17</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>DingTalkOA</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>DingTalkOA</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>com.dingtalk.open</groupId>
            <artifactId>app-stream-client</artifactId>
            <version>1.1.0</version>
        </dependency>

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

创建好之后可以在OA里面找到刚才创建的审批流模板

审批表单模板创建结束后,钉钉会返回一个processCode给我们,这个processCode很重要需要保存下来。整体来说,审批表单模板的创建不难理解,毕竟在这里不需要设置各个环节的审批人,真正复杂的是发起审批实例这个接口,下面我们来讲一下如何发起审批实例。

三、发起审批实例

1. 参数说明

  • processCode
    审批模板code,好理解。
  • originatorUserId
    审批发起人的userId,也理解。
  • deptId或approvers
    选择审批的部门或者审批的人,这是二选一,传一个就行了,理解上还行。
  • ccList
    抄送人 userId,就是只会通知到他,而不用他点审批的人,好理解。
  • ccPosition
    抄送时间点,取值:START:开始时抄送;FINISH:结束时抄送;START_FINISH:开始和结束时都抄送,理解上还行。
  • formComponentValues
    表单数据内容,控件列表,也就是我们创建的那些控件的具体的值,理解上还行。
  • microappAgentId
    不理解的来了,这个东西如果你是企业内部应用,你可以很快的在应用信息中找到,如下图

    但是!!!谁能告诉我第三方企业应用的agentId在哪???下面是第三方企业应用的应用信息,根本就没有!!!

    这是钉钉官方教我们查看官方应用和第三方应用的AgentId的方法

    但是!!!钉钉OA升级了了,不是,你们特么升级版本不考虑一下这个的吗???新版本没了,找不到了,打开是这个东西

    而且开放平台里面也根本没有可以直接获取AgentId的接口,最后找来找去,终于给找到一个接口:获取企业授权信息,这个接口的返回值里面有一个auth_info,里面有授权应用的agentId,唉,,,

2. 调用示例

官方文档:发起审批实例
这里我自己发起实例的代码如下:

package com.example.dingtalkoa.demo;

import java.util.ArrayList;
import java.util.List;

import com.alibaba.fastjson.JSONObject;

import com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestApprovers;
import com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceResponse;
import com.aliyun.tea.TeaException;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;

public class Sample4 {
    /**
     * 获取AccessToken
     *
     * @return
     */
    public static String getAccessToken() throws Exception {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        com.aliyun.dingtalkoauth2_1_0.Client client = new com.aliyun.dingtalkoauth2_1_0.Client(config);
        com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest getAccessTokenRequest
            = new com.aliyun.dingtalkoauth2_1_0.models.GetAccessTokenRequest()
            .setAppKey("xxx")
            .setAppSecret("xxx");
        try {
            return client.getAccessToken(getAccessTokenRequest).getBody().getAccessToken();
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                // err 中含有 code 和 message 属性,可帮助开发定位问题
            }

        }
        return null;
    }

    /**
     * 使用 Token 初始化账号Client
     *
     * @return Client
     * @throws Exception
     */
    public static com.aliyun.dingtalkworkflow_1_0.Client createClient() throws Exception {
        Config config = new Config();
        config.protocol = "https";
        config.regionId = "central";
        return new com.aliyun.dingtalkworkflow_1_0.Client(config);
    }

    public static void main(String[] args_) throws Exception {
        //调用钉钉审核发起接口
        com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues
            formComponentValues0
            =
            new com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues()
                .setName("TextField-title")
                .setValue("测试文章标题");

        com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues
            formComponentValues1
            =
            new com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues()
                .setName("TextField-url")
                .setValue("https://baidu.com");
        //获取审批人

        List<StartProcessInstanceRequestApprovers> approvers = new ArrayList<>();

        approvers.add(
            new com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestApprovers()
                .setActionType("NONE")
                .setUserIds(java.util.Arrays.asList(
                    "xxx"
                )));

        com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest startProcessInstanceRequest
            = new com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest()
            //.setDeptId(1L)
            .setApprovers(approvers)
            .setMicroappAgentId(xxx)
            .setOriginatorUserId("xxx")
            .setProcessCode("xxx")
            .setFormComponentValues(java.util.Arrays.asList(
                formComponentValues0,
                formComponentValues1
            ));

        com.aliyun.dingtalkworkflow_1_0.Client client = createClient();
        com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceHeaders startProcessInstanceHeaders
            = new com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceHeaders();
        startProcessInstanceHeaders.xAcsDingtalkAccessToken = getAccessToken();
        JSONObject.toJSONString(startProcessInstanceRequest);
        StartProcessInstanceResponse startProcessInstanceResponse = client.startProcessInstanceWithOptions(
            startProcessInstanceRequest, startProcessInstanceHeaders,
            new RuntimeOptions());

    }
}

把参数都准备好之后,实现起来还是比较简单的,调用代码创建的审批实例,钉钉会返回一个实例ID:instanceId,这个instanceId和processCode一样也需要保存下来,发送成功后钉钉APP上就会自动出现一条OA审批啦。

四、审批实例状态监控

所谓审批实例状态监控,就是当前审批流程是被同意啦还是被拒绝了。这里有两种方案:

  1. 定时去调用获取单个审批实例详情接口,同步审批实例状态,优点是状态肯定可以同步到,缺点是实时性差;
  2. 通过事件订阅的方式获取审批实例的状态,优点是实时性高,审批状态变化服务端就可以指定,缺点是只会推送一次;

而作为一个成年人,这两个肯定是全都要啦,一个用来实时更新,一个用来做兜底。
这里查询的审批实例的接口文档链接如下:获取单个审批实例详情
如果前面创建审批模板、发起审批实例都能跑通,那么这个接口也肯定不在话下,所以这里我就不贴代码了。

最后我把事件订阅推送的数据格式贴一下:

[
    {
        "result": "refuse",
        "processInstanceId": "xxx",
        "eventId": "xxx",
        "finishTime": 1698231807000,
        "createTime": 1698227806000,
        "processCode": "PROC-xxx",
        "businessId": "xxx",
        "title": "xxx提交的文章发布申请",
        "type": "finish",
        "staffId": "xxx",
        "taskId": "xxx"
    }
]

写在最后:其实这些东西大部分都是钉钉官方文档上面的,除了那个agentId... 但是钉钉文档的东西实在是太多,作为一个开发者,我们不可能去从头到尾看一遍的,一般都是用到了就去找。但是这样一来又会很混乱,所以我这篇文章主要是从开发者角度来梳理一下这个流程,不仅利己也能帮助其他人。

posted @ 2023-10-25 19:29  sum墨  阅读(2482)  评论(0编辑  收藏  举报