工作小结-钉钉OA审批(2) | 字数总计: 5.7k | 阅读时长: 22分钟 | 阅读量:
前言
在 工作小结-钉钉OA审批 中,介绍了钉钉OA审批的基础概念,以及常用的API示例。本章则主要是整理项目搭建钉钉OA审批底层框架的设计思路,并附上源码。
源码地址:https://github.com/wangfarui/java-study/tree/main/third-study/dingtalk/dingtalk-oa
框架目录结构
annotation: 关于钉钉OA审批的自定义注解
client:与钉钉服务端API交互
config:钉钉的属性配置、钉钉相关的Spring Bean配置类
dao:数据持久层
form:钉钉OA审批表单
listener:钉钉回调事件监听器
mapper:数据ORM映射层
model:数据表实体;钉钉OA审批相关对象、枚举
runner:服务启动时,钉钉的启动器
service:钉钉服务层
util:钉钉相关的工具类,例如表单数据格式化工具、Client统一生成工具等
DingTalkAuthManager:钉钉鉴权服务管理器,获取钉钉参数配置的入口
设计思路
数据表设计
钉钉创建审批表单模板需要 name(模板名称)、formComponents(表单控件)等,因此需要一张审批表单关联表(dd_approval_form_rel )记录业务表单与钉钉表单模板之间的关联信息。
钉钉发起OA审批需要 processCode(模板编号)、originatorUserId(发起人id)、deptId(发起人部门id)等,processCode可以从审批表单关联表获取,但发起人信息得从钉钉获取,获取方式是通过业务系统与钉钉有唯一关联关系的值(例如手机号)进行关联查询,而唯一关联关系的值一般情况下不会变动,所以为了减少不必要的网络请求开销,就需要一张用户关联表(dd_user_rel )记录钉钉用户与业务系统用户之间的关联关系。
在发起OA审批后,钉钉会返回一个 instanceId(审批实例id),通过审批实例id可以随时查看该审批记录的最新操作记录、任务状态、表单控件数据等。为了审批回调时能够快速定位到业务系统的业务单据,所以需要一个审批业务关联表(dd_approval_business_rel )记录钉钉OA审批实例与业务系统的业务单据的关联关系。
因为业务需要,钉钉审批流程的记录希望同步展示到业务系统页面上。通过跑审批示例流程观察,审批流程记录的数据节点与钉钉回调事件中的审批任务事件返回数据节点是一致的,因此审批记录就监听该事件就好。审批记录的内容一般包括审批人、审批流程类型(同意、拒绝、评论、转交、撤销等)、审批评论内容、审批时间,其中审批评论内容支持评论图片以及上传附件,所以需要一个审批流程记录表(dd_approval_process_log )和一个审批流程记录附件表(dd_approval_process_log_attachment )。
关键类设计
钉钉的服务端API基本上都要token入参,有些接口还需要用户id、应用id、存储空间id等,而这些参数基本上都是“固定的”。因此需要一个统一获取钉钉参数配置类,并且这个类具有缓存效果。(DingTalkAuthManager )
钉钉服务端API的交互方式是使用SDK,而SDK又分为新版SDK和旧版SDK,为了方便管理,因此使用 DingTalkApiClientUtil 创建Client,在 client 包下实现与API的交互,根据Client类型分为多个类进行管理。
因为是搭建钉钉OA审批底层框架,要支持业务服务能够快捷、易用、低侵入的接入,同时支持各种场景下的审批控件内容,就需要设计一个表单工具类,可以解析各种表单控件类型并格式化表单数据。
钉钉OA审批的表单控件类型都是纯文本描述的,SDK未提供相关的字典类,所以首先需要自己维护一个表单控件类型字典(ComponentType ),为了尽可能的易用,我创造了一个 AUTO 控件类型,让框架通过“约定”的方式自动识别控件类型。相应实现方法在 DingTalkFormUtil#generateFormComponent(Field field) 。
不同的表单控件类型在发起审批实例时,需要的表单数据格式是不一样的(在整体上如钉钉开放平台文档所说确实结构一致),例如表格、附件、关联审批单等,它们的 value 参数值就与平常控件区别很大。相应实现方法在 DingTalkFormUtil#generateFormComponentValues(Field field, ApprovalFormEngine approvalForm) 。
第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 () ; String id () default "" ; ComponentType componentType () default ComponentType.AUTO; boolean required () default false ; String pattern () default "yyyy-MM-dd HH:mm:ss" ; }
发起审批流程
实现方法在 DingTalkApprovalService#startApprovalFlowInstance(ApprovalFormInstance instance) 。
发起审批流程可以细分为四部分:
创建并获取审批模板
获取审批发起人对应的钉钉用户信息
发起审批实例,拿取实例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 public class ApprovalFormInstance { private Long businessId; private ApprovalFormEngine approvalForm; private Long tenantId; private Long userId; private Long departmentId; private boolean selfRelateField = true ; }
在整个对象中,最关键的就是businessId
和approvalForm
,它们决定了此次发起审批流程的业务关联数据、审批表单数据。
创建并获取审批模板
实际上,第一步应该叫 获取审批模板编号 ,但编号不会凭空产生,所以方法内部的实际操作为:
查询审批表单模板关联信息。
查询结果分为两种:
关联信息不存在,创建表单模板并返回模板编号。
关联信息存在,判断当前模板版本与关联信息存储的版本是否一致。(版本的存在是为了防止业务端在开发过程中修改已存在的表单模板对象)
若一致,直接返回关联信息存储的模板编号。
若不一致,更新表单模板并返回模板编号。
代码如下:
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(); 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) { UserResponse userResponse = new UserResponse (); String ddUserId = DingTalkUserInfoClient.getUserIdByMobile(userResponse.getMobile()); 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 @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 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 () ; 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(); 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 ; } 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); 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 { 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 { void start () 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 <>();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); } } for (OpenDingTalkClient client : CLIENTS) { try { client.stop(); } catch (Exception e) { status = false ; log.error("钉钉Client关闭失败" , e); } } 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; 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(); 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()); Long tenantId = this .getDdApprovalBusinessRelService().getTenantIdByInstance(approvalBaseChange.getProcessInstanceId()); if (tenantId == null ) { log.warn("无法确定钉钉审批实例对应的租户信息,请确认审批实例数据来源是否合规." ); return ; } try { DingTalkConfig.setTenantIdContext(tenantId); 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 ; } 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) { Long count = ddApprovalProcessLogDao.lambdaQuery() .eq(DdApprovalProcessLog::getTenantId, tenantId) .eq(DdApprovalProcessLog::getEventId, taskChange.getEventId()) .count(); if (count > 0 ) { 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) { } 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 ; 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 "" ; } }
总结
钉钉开放平台的接口太“开放”了,大多数请求基本上都很难通过一个接口搞定。但人家这样设计自然有它的道理,个人猜想它的设计思路:
从功能模块方面:将每个接口所做的功能尽可能细化,做到服务端实现逻辑的解耦。
从接口响应方面:因为接口功能涉及影响面较小,需要处理的数据交互也小很多,响应时间也会缩短。
在搭建整个钉钉OA审批框架时,在包定义、类名定义上不够严谨,感觉子模块的区分还是没有做好,Config与Model有点混乱,还是需要多看看大佬的框架源码。
Client的异常处理,还是没有想出好的方法做通用处理,每个方法都要try-catch一模一样的内容。
没有考虑高并发情况,核心模块需要做锁处理;另外,没有考虑分布式微服务场景。
审批回调的重试机制不能单方面依赖钉钉,需要自己做一层异常重试处理机制;此外,钉钉重试间隔时间很短暂,因此回调处理时间不能太长。可以结合自己做异常重试机制,将回调事件做异步操作。