釘釘OA自定義審批流的建立和使用

2023-10-25 21:01:12

前言

大家好!我是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... 但是釘釘檔案的東西實在是太多,作為一個開發者,我們不可能去從頭到尾看一遍的,一般都是用到了就去找。但是這樣一來又會很混亂,所以我這篇文章主要是從開發者角度來梳理一下這個流程,不僅利己也能幫助其他人。