前言

工作小结-钉钉OA审批 中,介绍了钉钉OA审批的基础概念,以及常用的API示例。本章则主要是整理项目搭建钉钉OA审批底层框架的设计思路,并附上源码。

源码地址:https://github.com/wangfarui/java-study/tree/main/third-study/dingtalk/dingtalk-oa

框架目录结构

image-20230901165428927

annotation: 关于钉钉OA审批的自定义注解

client:与钉钉服务端API交互

config:钉钉的属性配置、钉钉相关的Spring Bean配置类

dao:数据持久层

form:钉钉OA审批表单

listener:钉钉回调事件监听器

mapper:数据ORM映射层

model:数据表实体;钉钉OA审批相关对象、枚举

runner:服务启动时,钉钉的启动器

service:钉钉服务层

util:钉钉相关的工具类,例如表单数据格式化工具、Client统一生成工具等

DingTalkAuthManager:钉钉鉴权服务管理器,获取钉钉参数配置的入口

设计思路

数据表设计

  1. 钉钉创建审批表单模板需要 name(模板名称)、formComponents(表单控件)等,因此需要一张审批表单关联表(dd_approval_form_rel)记录业务表单与钉钉表单模板之间的关联信息。
  2. 钉钉发起OA审批需要 processCode(模板编号)、originatorUserId(发起人id)、deptId(发起人部门id)等,processCode可以从审批表单关联表获取,但发起人信息得从钉钉获取,获取方式是通过业务系统与钉钉有唯一关联关系的值(例如手机号)进行关联查询,而唯一关联关系的值一般情况下不会变动,所以为了减少不必要的网络请求开销,就需要一张用户关联表(dd_user_rel)记录钉钉用户与业务系统用户之间的关联关系。
  3. 在发起OA审批后,钉钉会返回一个 instanceId(审批实例id),通过审批实例id可以随时查看该审批记录的最新操作记录、任务状态、表单控件数据等。为了审批回调时能够快速定位到业务系统的业务单据,所以需要一个审批业务关联表(dd_approval_business_rel)记录钉钉OA审批实例与业务系统的业务单据的关联关系。
  4. 因为业务需要,钉钉审批流程的记录希望同步展示到业务系统页面上。通过跑审批示例流程观察,审批流程记录的数据节点与钉钉回调事件中的审批任务事件返回数据节点是一致的,因此审批记录就监听该事件就好。审批记录的内容一般包括审批人、审批流程类型(同意、拒绝、评论、转交、撤销等)、审批评论内容、审批时间,其中审批评论内容支持评论图片以及上传附件,所以需要一个审批流程记录表(dd_approval_process_log)和一个审批流程记录附件表(dd_approval_process_log_attachment)。

关键类设计

  1. 钉钉的服务端API基本上都要token入参,有些接口还需要用户id、应用id、存储空间id等,而这些参数基本上都是“固定的”。因此需要一个统一获取钉钉参数配置类,并且这个类具有缓存效果。(DingTalkAuthManager

  2. 钉钉服务端API的交互方式是使用SDK,而SDK又分为新版SDK和旧版SDK,为了方便管理,因此使用 DingTalkApiClientUtil 创建Client,在 client 包下实现与API的交互,根据Client类型分为多个类进行管理。

    image-20230901174559277

  3. 因为是搭建钉钉OA审批底层框架,要支持业务服务能够快捷、易用、低侵入的接入,同时支持各种场景下的审批控件内容,就需要设计一个表单工具类,可以解析各种表单控件类型并格式化表单数据。

    1. 钉钉OA审批的表单控件类型都是纯文本描述的,SDK未提供相关的字典类,所以首先需要自己维护一个表单控件类型字典(ComponentType),为了尽可能的易用,我创造了一个 AUTO 控件类型,让框架通过“约定”的方式自动识别控件类型。相应实现方法在 DingTalkFormUtil#generateFormComponent(Field field)
    2. 不同的表单控件类型在发起审批实例时,需要的表单数据格式是不一样的(在整体上如钉钉开放平台文档所说确实结构一致),例如表格、附件、关联审批单等,它们的 value 参数值就与平常控件区别很大。相应实现方法在 DingTalkFormUtil#generateFormComponentValues(Field field, ApprovalFormEngine approvalForm)
  4. 第3条说的表单工具类是为了解析表单对象,表单对象就是一个Java Class类,通过注解(FormComponent)标记字段以表示表单控件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.FIELD)
    public @interface FormComponent {

    /**
    * 表单控件名称
    */
    String value();

    /**
    * 表单控件id
    * <p>表单控件列表中唯一</p>
    */
    String id() default "";

    /**
    * 表单控件类型
    */
    ComponentType componentType() default ComponentType.AUTO;

    /**
    * 是否非空, 默认不能为空
    */
    boolean required() default false;

    /**
    * 字段为{@link java.util.Date}时,日期格式化样式
    */
    String pattern() default "yyyy-MM-dd HH:mm:ss";
    }

发起审批流程

实现方法在 DingTalkApprovalService#startApprovalFlowInstance(ApprovalFormInstance instance)

发起审批流程可以细分为四部分:

  1. 创建并获取审批模板
  2. 获取审批发起人对应的钉钉用户信息
  3. 发起审批实例,拿取实例id
  4. 保存审批实例与业务单据的关联信息

入参对象分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class ApprovalFormInstance {

/**
* scm业务id
* <p>非空</p>
*/
private Long businessId;

/**
* 审批流程表单模板对象
* <p>非空</p>
*/
private ApprovalFormEngine approvalForm;

/**
* scm租户id
* <p>非空字段,为空时默认从UserUtils获取</p>
*/
private Long tenantId;

/**
* scm操作人id
* <p>非空字段,为空时默认从UserUtils获取</p>
*/
private Long userId;

/**
* 钉钉部门id
* <br>
* TODO 钉钉存在多部门时,需要指定。后期根据产品需求决定是手动指定还是默认指定
*/
private Long departmentId;

/**
* 自身关联审批单
*/
private boolean selfRelateField = true;
}

在整个对象中,最关键的就是businessIdapprovalForm,它们决定了此次发起审批流程的业务关联数据、审批表单数据。

创建并获取审批模板

实际上,第一步应该叫 获取审批模板编号 ,但编号不会凭空产生,所以方法内部的实际操作为:

  1. 查询审批表单模板关联信息。
  2. 查询结果分为两种:
    1. 关联信息不存在,创建表单模板并返回模板编号。
    2. 关联信息存在,判断当前模板版本与关联信息存储的版本是否一致。(版本的存在是为了防止业务端在开发过程中修改已存在的表单模板对象)
      1. 若一致,直接返回关联信息存储的模板编号。
      2. 若不一致,更新表单模板并返回模板编号。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public DdApprovalFormRel resolveDdApprovalFormRel(ApprovalFormInstance instance) {
ApprovalFormEngine approvalForm = instance.getApprovalForm();

// 从关联表中查询钉钉表单模板Code
DdApprovalFormRel ddApprovalFormRel = ddApprovalFormRelDao.lambdaQuery()
.eq(DdApprovalFormRel::getTenantId, instance.getTenantId())
.eq(DdApprovalFormRel::getTypeCode, approvalForm.getBusinessApprovalTypeEnum().getCode())
.last("limit 1")
.one();
if (ddApprovalFormRel != null) {
String version = approvalForm.version();
// 版本号是否一致
if (version.equals(ddApprovalFormRel.getFormVersion())) {
return ddApprovalFormRel;
}
return updateDdApprovalFormRel(approvalForm, ddApprovalFormRel);
}
// 关联表无映射时,生成关联数据并返回
return createDdApprovalFormRel(instance);
}

获取审批发起人对应的钉钉用户信息

首先,查询用户关联数据;

然后,关联数据为空的话,通过业务用户信息调用钉钉API,查询钉钉用户详情;

最后,建立业务用户与钉钉用户的关联关系数据对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public DdUserRel resolveDdUserRel(ApprovalFormInstance instance) {
// 查询已关联的用户信息
DdUserRel ddUserRel = ddUserRelDao.lambdaQuery()
.eq(DdUserRel::getTenantId, instance.getTenantId())
.eq(DdUserRel::getScmUserId, instance.getUserId())
.last("limit 1")
.one();
if (ddUserRel != null) {
return ddUserRel;
}
// 通过业务用户信息 创建钉钉用户关联数据
return createDdUserRelByScmUser(instance);
}

private DdUserRel createDdUserRelByBusinessUser(ApprovalFormInstance dto) {
// TODO 根据业务系统用户id查询用户详情
UserResponse userResponse = new UserResponse();

// 通过手机号码查询钉钉用户id
String ddUserId = DingTalkUserInfoClient.getUserIdByMobile(userResponse.getMobile());
// 通过钉钉用户id查询钉钉用户详情
OapiV2UserGetResponse.UserGetResponse ddUserRsp = DingTalkUserInfoClient.getUserById(ddUserId);

// 新增关联对象
DdUserRel newEntity = new DdUserRel();
newEntity.setTenantId(dto.getTenantId());
newEntity.setScmUserId(dto.getUserId());
newEntity.setDdUserId(ddUserId);
newEntity.setDdUserName(userResponse.getUserName());
newEntity.setUserMobile(userResponse.getMobile());
newEntity.setDdDeptId(ddUserRsp.getDeptIdList().get(0));
ddUserRelDao.save(newEntity);

return newEntity;
}

在通过业务用户信息查询钉钉用户详情时,关键参数是“手机号码”。同理,也可以通过钉钉用户的手机号码查询业务用户详情,在“钉钉OA审批回调事件”中就会用到此方法。

还有点需要注意的是,钉钉API目前只支持通过id查详情(不仅限于查询用户详情接口),所以针对查询钉钉id数据的接口,可以统一封装要求返回id不能为空。

发起审批实例

在第一步和第二步中分别拿到了发起审批实例需要的审批模板编号和审批发起人id,所以现在只需要准备表单数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 发起审批实例
*
* @param instance 审批表单流程实例对象
* @param ddUserRel 钉钉用户关联数据
* @param ddApprovalFormRel 钉钉审批表单关联数据
* @return instanceId
*/
@SafeVarargs
public static String processInstances(ApprovalFormInstance instance, DdUserRel ddUserRel, DdApprovalFormRel ddApprovalFormRel,
Supplier<StartProcessInstanceRequestFormComponentValues>... expandValues) {
ApprovalFormEngine approvalForm = instance.getApprovalForm();
// 请求头
StartProcessInstanceHeaders startProcessInstanceHeaders = new StartProcessInstanceHeaders();
startProcessInstanceHeaders.setXAcsDingtalkAccessToken(DingTalkAuthManager.getToken());
// 构建表单控件元素
List<StartProcessInstanceRequestFormComponentValues> formComponentValues = approvalForm.buildFormComponentValues();
// 组装扩展控件值
for (Supplier<StartProcessInstanceRequestFormComponentValues> supplier : expandValues) {
StartProcessInstanceRequestFormComponentValues value = supplier.get();
if (value != null) {
formComponentValues.add(value);
}
}
// 表单实例请求对象
StartProcessInstanceRequest startProcessInstanceRequest = new StartProcessInstanceRequest()
.setOriginatorUserId(ddUserRel.getDdUserId())
.setProcessCode(ddApprovalFormRel.getProcessCode())
.setDeptId(ddUserRel.getDdDeptId())
.setMicroappAgentId(DingTalkConfig.getTenantPropertiesValue(DingTalkTenantProperties::getAgentId))
.setFormComponentValues(formComponentValues);
// 创建Client并发送请求
Client workflowClient = DingTalkApiClientUtil.createWorkflowClient();
try {
StartProcessInstanceResponse response = workflowClient.startProcessInstanceWithOptions(
startProcessInstanceRequest, startProcessInstanceHeaders, new RuntimeOptions()
);
log.info("[DingTalkWorkflowClient]processInstances: {}", JSON.toJSONString(response));
return response.getBody().getInstanceId();
} catch (TeaException err) {
throw err;
} catch (Exception _err) {
throw new TeaException(_err.getMessage(), _err);
}
}

如你所见,由于当初开发时间节点问题以及业务需求的渗透,该方法没有经过 service 做二次封装,所以在扩展性方面不够友好,为了兼容业务需要的特殊控件,在构建表单控件元素时引入了扩展参数。

审批事件回调

钉钉回调事件是一个统一入口,OA审批事件是其中的一种。

注册钉钉回调事件分为Http、Stream两种,这里只展示Stream方式接入。

业务回调使用方式

业务实现审批回调后置处理的入口就是在Bean对象的方法上加上自定义注解 ApprovalCallback ,并配置相应的业务类型值、审批事件类型即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ApprovalCallback {

/**
* 业务审批类型
*/
BusinessApprovalTypeEnum value();

/**
* 审批事件类型
* <p>默认所有审批事件都回调</p>
*/
ApprovalEventType eventType() default ApprovalEventType.ALL;
}

在业务项目的OA审批中,审批结果大致分为审批同意、审批拒绝、审批中,所以审批事件类型枚举类就设计为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum ApprovalEventType {

/**
* 所有审批事件
*/
ALL,

/**
* 审批 - 同意
*/
AGREE,

/**
* 审批 - 拒绝
*/
REJECT,

/**
* 审批 - 进行中
*/
IN_PROGRESS

}

其中 ALL 表示所有审批结果。

回调方法实现原理

在Spring启动应用上下文时,指定扫描自定义的 DingTalkCallbackProcessor 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class DingTalkCallbackProcessor implements BeanPostProcessor {

@Override
public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
Class<?> clazz = bean.getClass();
// Bean初始化前, 扫描Bean的所有方法
for (Method method : findAllMethod(clazz)) {
processMethod(bean, method);
}
return bean;
}

private void processMethod(Object bean, Method method) {
ApprovalCallback annotation = AnnotationUtils.findAnnotation(method, ApprovalCallback.class);
if (annotation == null) {
return;
}

// 获取方法的入参,要求回调方法的入参个数有切仅有一个,并且入参对象继承自DingTalkCallbackEvent
Class<?>[] parameterTypes = method.getParameterTypes();
Preconditions.checkArgument(parameterTypes.length == 1,
"Invalid number of parameters: %s for method: %s, should be 1", parameterTypes.length,
method);
Preconditions.checkArgument(DingTalkCallbackEvent.class.isAssignableFrom(parameterTypes[0]),
"Invalid parameter type: %s for method: %s, should be DingTalkCallbackEvent", parameterTypes[0],
method);

ReflectionUtils.makeAccessible(method);

// 创建一个钉钉回调监听器,回调实现方法默认调用当前bean对象的方法
DingTalkCallbackListener dingTalkCallbackListener = (callbackEvent) ->
ReflectionUtils.invokeMethod(method, bean, callbackEvent);

// 将监听器添加到缓存
DingTalkConfig.addCallbackListener(annotation, dingTalkCallbackListener);
}

private List<Method> findAllMethod(Class<?> clazz) {
final List<Method> res = new LinkedList<>();
ReflectionUtils.doWithMethods(clazz, res::add);
return res;
}
}

DingTalkCallbackProcessor 类会在启动时,扫描所有Bean对象,并将带有 ApprovalCallback 注解的方法添加到容器中。

容器的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public abstract class DingTalkConfig {

/**
* 回调监听器的k-v为:业务审批类型 -> 审批事件类型 -> 监听器
*/
private static final Map<BusinessApprovalTypeEnum, Map<ApprovalEventType, List<DingTalkCallbackListener>>> CALLBACK_LISTENER_MAP;

static {
CALLBACK_LISTENER_MAP = new HashMap<>();
}

public static void addCallbackListener(ApprovalCallback annotation, DingTalkCallbackListener listener) {
Map<ApprovalEventType, List<DingTalkCallbackListener>> eventTypeMap = CALLBACK_LISTENER_MAP.computeIfAbsent(
annotation.value(), t -> new HashMap<>(1 << 2)
);
ApprovalEventType approvalEventType = annotation.eventType();
if (ApprovalEventType.ALL.equals(approvalEventType)) {
addCallbackListener(eventTypeMap, ApprovalEventType.AGREE, listener);
addCallbackListener(eventTypeMap, ApprovalEventType.REJECT, listener);
addCallbackListener(eventTypeMap, ApprovalEventType.IN_PROGRESS, listener);
} else {
addCallbackListener(eventTypeMap, approvalEventType, listener);
}

}

private static void addCallbackListener(Map<ApprovalEventType, List<DingTalkCallbackListener>> eventTypeMap,
ApprovalEventType approvalEventType,
DingTalkCallbackListener listener) {
List<DingTalkCallbackListener> listeners = eventTypeMap.get(approvalEventType);
if (listeners == null) {
listeners = new ArrayList<>(4);
}
listeners.add(listener);
eventTypeMap.put(approvalEventType, listeners);
}

public static List<DingTalkCallbackListener> getCallbackListener(BusinessApprovalTypeEnum approvalTypeEnum,
ApprovalEventType approvalEventType) {
Map<ApprovalEventType, List<DingTalkCallbackListener>> eventTypeMap = CALLBACK_LISTENER_MAP.get(approvalTypeEnum);
if (eventTypeMap == null) {
return null;
}
return eventTypeMap.get(approvalEventType);
}
}

通过注解将对应的监听器存放到指定key下,等钉钉回调时,根据钉钉审批回调数据判断审批事件类型,再通过业务审批关联表获取对应的业务审批类型,就可以拿到指定的监听器,发起回调。

回调统一处理方法

上文已经说过,钉钉事件回调可以通过Stream方法接入,钉钉用 OpenDingTalkClient 表示钉钉客户端接口,接口就两个方法:启动、停止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface OpenDingTalkClient {
/**
* 启动客户端
*
* @throws Exception
*/
void start() throws Exception;

/**
* 关闭客户端
*
* @throws Exception
*/
void stop() throws Exception;
}

在实例化客户端时,往里面插入事件监听器,再启动,就可以实现钉钉事件回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 钉钉客户端
*/
private static final List<OpenDingTalkClient> CLIENTS = new ArrayList<>();

/**
* 注册钉钉事件Stream
*/
private void registerEventStream() {
boolean status = true;
Collection<DingTalkTenantProperties> allUniqueAppKeyTenantProperties = DingTalkConfig.getUniqueAppKeyTenantProperties();
List<OpenDingTalkClient> clientList = new ArrayList<>(allUniqueAppKeyTenantProperties.size());
for (DingTalkTenantProperties properties : allUniqueAppKeyTenantProperties) {
OpenDingTalkClient dingTalkClient = OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(properties.getAppKey(), properties.getAppSecret()))
// 注册事件监听
.registerAllEventListener(new ApprovalCallbackEventListener())
.build();
try {
dingTalkClient.start();
clientList.add(dingTalkClient);
} catch (Exception e) {
log.error("钉钉AppKey为[" + properties.getAppKey() + "]的Stream Client启用失败", e);
throw new RuntimeException(e);
}
}
// 关闭已启动的client
for (OpenDingTalkClient client : CLIENTS) {
try {
client.stop();
} catch (Exception e) {
status = false;
log.error("钉钉Client关闭失败", e);
// ignore
}
}
CLIENTS.clear();
CLIENTS.addAll(clientList);
log.info("钉钉Client注册列表: " + allUniqueAppKeyTenantProperties);
log.info("钉钉Client注册" + (status ? "成功" : "异常"));
}

其中的 ApprovalCallbackEventListener 就是我们自定义实现的回调OA审批事件监听器,它需要实现钉钉的 GenericEventListener 接口。

1
2
3
4
5
6
7
8
9
10
11
12
public interface GenericEventListener {

GenericEventListener DEFAULT = event -> EventAckStatus.SUCCESS;

/**
* 收到事件
*
* @param event
* @return
*/
EventAckStatus onEvent(final GenericOpenDingTalkEvent event);
}

ApprovalCallbackEventListener 类继承 AbstractDingTalkStreamEventListener 类,表示OA审批回调监听器继承了钉钉回调事件监听器的功能。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public abstract class AbstractDingTalkStreamEventListener implements GenericEventListener {

/**
* 审批任务回调
*/
protected static final String BPMS_TASK_CHANGE = "bpms_task_change";

/**
* 审批实例回调
*/
protected static final String BPMS_INSTANCE_CHANGE = "bpms_instance_change";

@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
MDC.put("traceId", UUID.randomUUID().toString().replace("-", ""));
log.info("钉钉回调事件对象: " + JSON.toJSONString(event));
try {
//事件类型
String eventType = event.getEventType();

// 确定事件类型,决定data
switch (eventType) {
case BPMS_TASK_CHANGE:
case BPMS_INSTANCE_CHANGE:
DingTalkCallbackChange change = BeanUtil.copyProperties(event, DingTalkCallbackChange.class);
handleApprovalEvent(change);
break;
default: {
// 未知的事件类型,忽略
}
}

//消费成功
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("钉钉回调事件消费失败", e);
//消费失败
return EventAckStatus.LATER;
} finally {
MDC.remove("traceId");
}
}

protected void handleApprovalEvent(DingTalkCallbackChange eventChange) {

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@Slf4j
public class ApprovalCallbackEventListener extends AbstractDingTalkStreamEventListener {

private DdApprovalBusinessRelService ddApprovalBusinessRelService;

private DdUserRelService ddUserRelService;

private DdApprovalProcessLogService ddApprovalProcessLogService;

@Override
public void handleApprovalEvent(DingTalkCallbackChange eventChange) {
ApprovalCallbackEvent approvalCallbackEvent = new ApprovalCallbackEvent();

ApprovalBaseChange approvalBaseChange;
if (BPMS_TASK_CHANGE.equals(eventChange.getEventType())) {
approvalCallbackEvent.setApprovalEventType(ApprovalEventType.IN_PROGRESS);
approvalBaseChange = BeanUtil.copyProperties(eventChange.getData(), ApprovalTaskChange.class);
} else {
ApprovalInstanceChange instanceChange = BeanUtil.copyProperties(eventChange.getData(), ApprovalInstanceChange.class);
approvalBaseChange = instanceChange;

if (ApprovalProcessType.TYPE_FINISH.equals(instanceChange.getType())) {
if (ApprovalProcessType.RESULT_AGREE.equals(instanceChange.getResult())) {
approvalCallbackEvent.setApprovalEventType(ApprovalEventType.AGREE);
} else if (ApprovalProcessType.RESULT_REFUSE.equals(instanceChange.getResult())) {
approvalCallbackEvent.setApprovalEventType(ApprovalEventType.REJECT);
} else {
log.warn("未知的审批实例结果. instanceChange: " + instanceChange);
return;
}
} else if (ApprovalProcessType.TYPE_TERMINATE.equals(instanceChange.getType())) {
log.info("发起人撤销审批单. instanceChange: " + instanceChange);
approvalCallbackEvent.setApprovalEventType(ApprovalEventType.REJECT);
} else {
log.warn("未知的审批实例状态变更类型. instanceChange: " + instanceChange);
return;
}
}
// 设置事件基础值
approvalBaseChange.setEventId(eventChange.getEventId());
approvalBaseChange.setEventType(eventChange.getEventType());
approvalBaseChange.setEventBornTime(eventChange.getEventBornTime());

// 查询审批实例对应的租户id
Long tenantId = this.getDdApprovalBusinessRelService().getTenantIdByInstance(approvalBaseChange.getProcessInstanceId());
if (tenantId == null) {
log.warn("无法确定钉钉审批实例对应的租户信息,请确认审批实例数据来源是否合规.");
return;
}

// TODO 业务系统 手动切换数据源

try {
// 上下文配置租户信息
DingTalkConfig.setTenantIdContext(tenantId);

// 根据流程实例id 查询审批业务关联单据
DdApprovalBusinessRel ddApprovalBusinessRel = this.getDdApprovalBusinessRelService().getDdApprovalBusinessRelByInstance(approvalBaseChange.getProcessInstanceId());

String tenantName = DingTalkConfig.getTenantPropertiesValue(tenantId, DingTalkTenantProperties::getTenantName);
if (ddApprovalBusinessRel == null) {
log.error(String.format("租户[%s]审批实例不存在, instanceId: %s", tenantName, approvalBaseChange.getProcessInstanceId()));
return;
}

// 根据code获取枚举
BusinessApprovalTypeEnum approvalTypeEnum = BusinessApprovalTypeEnum.getApprovalFormEnum(ddApprovalBusinessRel.getTypeCode());
if (approvalTypeEnum == null) {
log.error(String.format("租户[%s]找不到对应的业务审批类型,请确认是否进行过变更。业务审批关联数据: %s", tenantName, ddApprovalBusinessRel));
return;
}

// 获取审批人
DdUserRel ddUserRel = getDdUserRelService().resolveDdUserRel(tenantId, approvalBaseChange.getStaffId());
if (ddUserRel == null) {
log.warn(String.format("租户[%s]无法找到可以关联的用户,钉钉用户id为[%s]", tenantName, approvalBaseChange.getStaffId()));
} else {
approvalCallbackEvent.setUserId(ddUserRel.getScmUserId());
approvalBaseChange.setStaffName(ddUserRel.getDdUserName());
}

// 根据审批任务生成审批流程日志
if (approvalBaseChange instanceof ApprovalTaskChange) {
this.getDdApprovalProcessLogService().generateLog((ApprovalTaskChange) approvalBaseChange, tenantId, ddApprovalBusinessRel.getId());
}

// 根据枚举+钉钉事件类型 调用监听的方法
List<DingTalkCallbackListener> listeners = DingTalkConfig.getCallbackListener(approvalTypeEnum, approvalCallbackEvent.getApprovalEventType());
if (listeners == null) {
log.warn(String.format("租户[%s]未配置审批类型为[%s],事件类型为[%s]的回调监听器, 跳过钉钉回调事件", tenantName, approvalTypeEnum.getName(), approvalCallbackEvent.getApprovalEventType().name()));
return;
}

// 填充回调事件对象数据
approvalCallbackEvent.setEventId(eventChange.getEventId());
approvalCallbackEvent.setEventType(eventChange.getEventType());
approvalCallbackEvent.setEventBornTime(eventChange.getEventBornTime());
approvalCallbackEvent.setTenantId(tenantId);
approvalCallbackEvent.setBusinessId(ddApprovalBusinessRel.getBusinessId());

// 业务回调处理
for (DingTalkCallbackListener listener : listeners) {
listener.callback(approvalCallbackEvent);
}

log.info(String.format("租户[%s]处理钉钉回调事件完成, 回调事件对象: %s", tenantName, approvalCallbackEvent));
} finally {
DingTalkConfig.removeTenantIdContext();
}
}

private DdApprovalProcessLogService getDdApprovalProcessLogService() {
if (this.ddApprovalProcessLogService == null) {
this.ddApprovalProcessLogService = SpringUtil.getBean(DdApprovalProcessLogService.class);
}
return this.ddApprovalProcessLogService;
}

private DdApprovalBusinessRelService getDdApprovalBusinessRelService() {
if (this.ddApprovalBusinessRelService == null) {
this.ddApprovalBusinessRelService = SpringUtil.getBean(DdApprovalBusinessRelService.class);
}
return this.ddApprovalBusinessRelService;
}

private DdUserRelService getDdUserRelService() {
if (this.ddUserRelService == null) {
this.ddUserRelService = SpringUtil.getBean(DdUserRelService.class);
}
return this.ddUserRelService;
}
}

AbstractDingTalkStreamEventListener 通过模板模式,将审批事件的实现方法进行抽象。这样做的目的是为了后期可能会接入钉钉的其他事件类型,例如通讯录变更事件。。。

ApprovalCallbackEventListener 重写的 handleApprovalEvent 方法的最后,可以看到通过遍历 listeners 变量,回调了业务方法。

回调的流程记录

钉钉OA审批流程记录的数据与审批任务是保持一致的,因此在审批事件回调中,对审批事件类型为审批任务的事件进行拦截处理即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
@Service
public class DdApprovalProcessLogService {

@Resource
private DdApprovalProcessLogDao ddApprovalProcessLogDao;

@Resource
private DdApprovalProcessLogAttachmentDao ddApprovalProcessLogAttachmentDao;

@Transactional(rollbackFor = Exception.class)
public void generateLog(ApprovalTaskChange taskChange, Long tenantId, Long businessRelId) {
// 钉钉事件id已存在业务系统数据表中,忽略重复请求
Long count = ddApprovalProcessLogDao.lambdaQuery()
.eq(DdApprovalProcessLog::getTenantId, tenantId)
.eq(DdApprovalProcessLog::getEventId, taskChange.getEventId())
.count();
if (count > 0) {
// 已生成日志 ignore
return;
}

// 保存审批流程记录
DdApprovalProcessLog approvalProcessLog = new DdApprovalProcessLog();
approvalProcessLog.setTenantId(tenantId);
approvalProcessLog.setBusinessRelId(businessRelId);
approvalProcessLog.setEventId(taskChange.getEventId());
approvalProcessLog.setTaskId(taskChange.getTaskId());
approvalProcessLog.setStaffId(taskChange.getStaffId());
approvalProcessLog.setStaffName(taskChange.getStaffName());
approvalProcessLog.setProcessContent(taskChange.getContent() != null ? taskChange.getContent() : taskChange.getRemark());
approvalProcessLog.setCreateTime(taskChange.getEventBornTime());
ApprovalProcessType processType = ApprovalProcessType.confirmProcessType(taskChange.getType(), taskChange.getResult());
approvalProcessLog.setProcessType(processType.getCode());
ddApprovalProcessLogDao.save(approvalProcessLog);

// 保存当前审批操作中附带的附件
this.saveLogAttachment(taskChange, tenantId, approvalProcessLog.getId());
}

private void saveLogAttachment(ApprovalTaskChange taskChange, Long tenantId, Long logId) {
// 查询审批实例详情
GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResult instanceResponse = DingTalkWorkflowClient.getProcessInstanceById(taskChange.getProcessInstanceId());
List<GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultOperationRecords> operationRecords = instanceResponse.getOperationRecords();
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'");
String eventBornTime = simpleDateFormat.format(taskChange.getEventBornTime());
// 遍历审批实例详情中的操作记录,找到与当前审批操作匹配的记录
for (int len = operationRecords.size() - 1; len >= 0; len--) {
GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultOperationRecords records = operationRecords.get(len);
if (!taskChange.getStaffId().equals(records.getUserId())) {
continue;
}
if (!eventBornTime.equals(records.getDate())) {
try {
Date parse = simpleDateFormat.parse(records.getDate());
long after = parse.getTime() + (60 * 1000);
if (after < taskChange.getEventBornTime().getTime()) {
// 操作记录的时间点 小于 事件发生的事件点
break;
}
} catch (ParseException e) {
// ignore
}
continue;
}
// 匹配到审批操作记录,判断当前审批操作是否携带附件
if (CollectionUtils.isNotEmpty(records.getAttachments())) {
List<String> fileIdList = records.getAttachments()
.stream()
.map(GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultOperationRecordsAttachments::getFileId)
.collect(Collectors.toList());
// 为附件临时授权可访问
DingTalkWorkflowClient.authorizeApprovalDentry(fileIdList);
// 遍历获取附件信息并上传到业务系统文件服务器,保存审批流程记录的附件信息
for (GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultOperationRecordsAttachments attachment : records.getAttachments()) {
String wyysFileUrl = this.uploadFileToBusinessSystem(attachment.getFileId(), attachment.getFileName());
if (wyysFileUrl == null) continue;
DdApprovalProcessLogAttachment logAttachment = new DdApprovalProcessLogAttachment();
logAttachment.setTenantId(tenantId);
logAttachment.setLogId(logId);
logAttachment.setFileName(attachment.getFileName());
logAttachment.setFileType(attachment.getFileType());
logAttachment.setFileUrl(wyysFileUrl);
ddApprovalProcessLogAttachmentDao.save(logAttachment);
}
}
break;
}
}

private String uploadFileToBusinessSystem(String fileId, String fileName) {
GetFileDownloadInfoResponseBody.GetFileDownloadInfoResponseBodyHeaderSignatureInfo headerSignatureInfo = DingTalkStorageClient.getFileDownloadInfo(fileId);
if (headerSignatureInfo == null) return null;

// http下载文件
HttpRequest httpRequest = HttpRequest.post(headerSignatureInfo.getResourceUrls().get(0));
for (Map.Entry<String, String> entry : headerSignatureInfo.getHeaders().entrySet()) {
httpRequest.header(entry.getKey(), entry.getValue());
}
HttpResponse httpResponse = httpRequest.execute();
InputStream inputStream = httpResponse.bodyStream();

// 保存到业务系统文件服务器,并返回全量路径地址
return "";
}

}

总结

  1. 钉钉开放平台的接口太“开放”了,大多数请求基本上都很难通过一个接口搞定。但人家这样设计自然有它的道理,个人猜想它的设计思路:
    1. 从功能模块方面:将每个接口所做的功能尽可能细化,做到服务端实现逻辑的解耦。
    2. 从接口响应方面:因为接口功能涉及影响面较小,需要处理的数据交互也小很多,响应时间也会缩短。
  2. 在搭建整个钉钉OA审批框架时,在包定义、类名定义上不够严谨,感觉子模块的区分还是没有做好,Config与Model有点混乱,还是需要多看看大佬的框架源码。
  3. Client的异常处理,还是没有想出好的方法做通用处理,每个方法都要try-catch一模一样的内容。
  4. 没有考虑高并发情况,核心模块需要做锁处理;另外,没有考虑分布式微服务场景。
  5. 审批回调的重试机制不能单方面依赖钉钉,需要自己做一层异常重试处理机制;此外,钉钉重试间隔时间很短暂,因此回调处理时间不能太长。可以结合自己做异常重试机制,将回调事件做异步操作。