跳到主要内容

钉钉集成

钉钉集成 Starter 提供了完整的钉钉开放平台 SDK 封装,支持企业内部应用、第三方应用、H5 微应用等多种应用类型。通过简单的配置即可快速集成钉钉的各种功能。

主要特性

  • 应用类型支持: 企业内部应用、第三方应用、H5 微应用
  • API 封装: 覆盖钉钉开放平台的主要 API
  • 工作流集成: 支持钉钉 OA 审批流程
  • 消息推送: 支持企业消息、机器人消息等
  • 卡片消息: 支持互动卡片功能
  • 事件订阅: 支持钉钉事件回调处理
  • 缓存支持: 内置 Token 缓存机制
  • 限流控制: 支持 API 调用频率限制
  • 重试机制: 支持请求失败自动重试

安装

Maven 安装

在您的 pom.xml 中添加以下依赖:

<dependency>
<groupId>com.jeeapp.spring.boot</groupId>
<artifactId>dingtalk-spring-boot-starter</artifactId>
</dependency>

Gradle 安装

在您的 build.gradle 中添加以下依赖:

implementation 'com.jeeapp.spring.boot:dingtalk-spring-boot-starter'

配置

基础配置

application.yml 中添加钉钉配置:

dingtalk:
# 企业 ID
corp-id: your-corp-id
# 企业 Secret
corp-secret: your-corp-secret
# 应用类型:CORP(企业内部应用)、ISV(第三方应用)、CUSTOM(H5微应用)
app-type: CORP
# 应用唯一标识
unified-app-id: your-unified-app-id
# 应用 Key
client-id: your-app-key
# 应用 Secret
client-secret: your-app-secret
# 企业内部应用 Agent ID
agent-id: your-agent-id

ISV 应用配置

对于第三方应用,需要额外配置:

dingtalk:
app-type: ISV
# ISV 套件 ID
suite-id: your-suite-id
# ISV 应用 ID
app-id: your-app-id
# 套件 Key
client-id: your-suite-key
# 套件 Secret
client-secret: your-suite-secret

事件回调配置

如果需要接收钉钉事件回调:

dingtalk:
# 事件订阅签名 Token
callback-token: your-callback-token
# 事件订阅消息体加解密 Key
callback-aes-key: your-callback-aes-key
# 机器人编码(可选)
robot-code: your-robot-code

Stream 模式配置

支持钉钉 Stream 模式事件订阅:

dingtalk:
# Stream 模式订阅主题(事件)
stream-topics:
- "*" # 事件回调
- "/v1.0/im/bot/messages/get" # 机器人消息回调
- "/v1.0/card/instances/callback" # 卡片交互回调
- "/v1.0/card/dynamicData/get" # 卡片动态数据源
- "/v1.0/graph/api/invoke" # Graph API 回调

高级配置

重试配置

dingtalk:
retry:
# 是否启用重试
enabled: true
# 最大重试次数
max-attempts: 3
# 重试间隔(毫秒)
backoff-period: 1000
# 需要重试的错误码
retry-error-codes:
- "40001"
- "40014"

限流配置

dingtalk:
rate-limits:
- path: "/v1.0/contact/users/*"
limit: 100
window: 60
- path: "/v1.0/workflow/processes/*"
limit: 50
window: 60

使用指南

基础使用

钉钉同时存在 旧版 SDKcom.dingtalk:dingtalk-sdk,OAPI / OapiXxxRequest 风格)和 新版 SDKcom.aliyun:dingtalk,REST 风格的 **_**.Client)。DingTalkTemplate 同时封装了对这两套 SDK 的调用,使用方只需要注入 DingTalkTemplate,无需自行处理 access_token、签名、限流、重试等细节。

@Autowired
private DingTalkTemplate dingTalkTemplate;

调用旧版 SDK(OapiXxxRequest

DingTalkTemplate 实现了 DingTalkOperations,提供以下方法用于调用旧版 OAPI:

方法说明
<T extends TaobaoResponse> T execute(TaobaoRequest<T> request)根据 request 自动解析接口地址并请求旧版 API,自动注入 access_token
<T extends TaobaoResponse> T execute(String url, TaobaoRequest<T> request)指定接口地址请求旧版 API(用于 SDK 未覆盖的旧接口)
<T extends TaobaoResponse> T execute(String url, Map<String, Object> requestMap)指定接口地址 + 参数 Map 请求旧版 API(用于 SDK 未覆盖的旧接口)

DingTalkTemplate 内部会根据请求类型自动注入对应凭证(access_token / sso_access_token / suite_access_token / clientId+clientSecret),并按 dingtalk.retry.*dingtalk.rate-limits 进行重试和限流。

import com.dingtalk.api.request.OapiV2UserGetRequest;
import com.dingtalk.api.response.OapiV2UserGetResponse;
import com.taobao.api.ApiException;

@Service
public class UserService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public OapiV2UserGetResponse.UserGetResponse getUser(String userId) throws ApiException {
OapiV2UserGetRequest request = new OapiV2UserGetRequest();
request.setUserid(userId);
request.setLanguage("zh_CN");

// 自动解析接口地址并注入 access_token
OapiV2UserGetResponse response = dingTalkTemplate.execute(request);
return response.getResult();
}

/**
* 指定接口地址(适用于 SDK 未覆盖的旧接口)
*/
public Object callRawApi(String userId) throws ApiException {
Map<String, Object> params = new HashMap<>();
params.put("userid", userId);
return dingTalkTemplate.execute("https://oapi.dingtalk.com/topapi/v2/user/get", params);
}
}

调用新版 SDK(com.aliyun.dingtalk*.Client

新版 SDK 按业务域拆分为多个 Client 类,例如:

  • com.aliyun.dingtalkcontact_1_0.Client:通讯录
  • com.aliyun.dingtalkworkflow_1_0.Client:审批
  • com.aliyun.dingtalkim_1_0.Client:IM 消息
  • com.aliyun.dingtalkyida_1_0.Client / com.aliyun.dingtalkyida_2_0.Client:宜搭

通过 DingTalkTemplate#createClient(Class) 获取 Client 实例,模板会自动注入 Config,并在请求头中设置 x-acs-dingtalk-access-token(工作台 API 自动改用 apiToken),同时挂载限流与重试拦截器:

import com.aliyun.dingtalkcontact_1_0.Client;
import com.aliyun.dingtalkcontact_1_0.models.GetUserHeaders;
import com.aliyun.tea.TeaException;
import com.aliyun.teautil.models.RuntimeOptions;

@Service
public class ContactService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public Object getUser(String unionId) throws Exception {
// 自动注入 access_token、限流、重试
Client client = dingTalkTemplate.createClient(Client.class);

GetUserHeaders headers = new GetUserHeaders();
// 大多数情况下无需手动设置 token,模板已注入到 globalParameters.headers
// headers.xAcsDingtalkAccessToken = dingTalkTemplate.getAccessToken();

return client.getUserWithOptions(unionId, headers, new RuntimeOptions()).getBody();
}
}
备注

createClient 每次调用都会构造一个新的 Client 实例并刷新 token,无需缓存。生产环境对同一业务域的多次调用建议复用 DingTalkTemplate(其内部会缓存 access_token)。

获取凭证与配置

DingTalkTemplate 还提供了一组常用方法:

方法说明
getAccessToken()获取应用授权企业的 access_token(带缓存)
getSsoAccessToken()获取 SSO access_token
getSuiteAccessToken()获取 ISV 套件 suite_access_token
getApiToken()获取工作台 API token(仅 dingtalkworkbench_1_0 使用)
getJsapiTicket(String url)生成 JSAPI 鉴权所需的 corpId/agentId/timeStamp/nonceStr/signature
getAppConfig()获取当前应用配置(corpId/clientId/agentId 等)
createAppLink(String)构造 AppLink 跳转地址
String accessToken = dingTalkTemplate.getAccessToken();
Map<String, Object> jsapiConfig = dingTalkTemplate.getJsapiTicket(currentPageUrl);
Long agentId = dingTalkTemplate.getAppConfig().getAgentId();

工作流操作

钉钉 OA 审批流程操作:

创建审批实例

使用注解方式定义表单对象:

import com.jeeapp.dingtalk.DingTalkTemplate;
import com.jeeapp.dingtalk.WorkflowOperations;
import com.jeeapp.dingtalk.annotation.ProcessBean;
import com.jeeapp.dingtalk.annotation.FormComponent;
import com.jeeapp.dingtalk.request.workflow.FormComponentType;
import com.jeeapp.dingtalk.request.workflow.ProcessInstanceBuilder;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public String createLeaveProcess() throws Exception {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

// 创建流程表单对象
LeaveProcessBean processBean = new LeaveProcessBean();
processBean.setOriginatorUserId("user123");
processBean.setApplicant("张三");
processBean.setLeaveType("年假");
processBean.setStartDate(LocalDate.now());
processBean.setEndDate(LocalDate.now().plusDays(3));
processBean.setDays(new BigDecimal("3"));
processBean.setReason("家中有事需要请假");

// 通过表单对象创建审批实例
ProcessInstanceBuilder builder = workflowOps.fromProcessBean(processBean);

// 使用新版 SDK 发起审批
String instanceId = builder.startV2();
System.out.println("创建审批实例: " + instanceId);

return instanceId;
}
}

// 流程表单定义
@ProcessBean(code = "PROC-XXXXX", name = "请假申请")
class LeaveProcessBean {

// 发起人 userId(必填)
private String originatorUserId;

// 发起人所在部门 ID
private Long originatorDeptId = -1L;

@FormComponent(name = "申请人", type = FormComponentType.TEXT)
private String applicant;

@FormComponent(name = "请假类型", type = FormComponentType.SELECT)
private String leaveType;

@FormComponent(name = "开始日期", type = FormComponentType.DATE, pattern = "yyyy-MM-dd")
private LocalDate startDate;

@FormComponent(name = "结束日期", type = FormComponentType.DATE, pattern = "yyyy-MM-dd")
private LocalDate endDate;

@FormComponent(name = "请假天数", type = FormComponentType.NUMBER)
private BigDecimal days;

@FormComponent(name = "请假事由", type = FormComponentType.TEXTAREA)
private String reason;

// getters and setters
}

支持的表单组件类型

钉钉审批支持多种表单组件类型:

组件类型常量说明Java 类型
FormComponentType.TEXT单行文本String
FormComponentType.TEXTAREA多行文本String
FormComponentType.NUMBER数字输入框BigDecimal, Integer, Long
FormComponentType.MONEY金额BigDecimal, MoneyComponent
FormComponentType.DATE日期LocalDate, String
FormComponentType.DATE_RANGE日期区间List<String>, String
FormComponentType.SELECT单选框String
FormComponentType.MULTI_SELECT多选框List<String>, String
FormComponentType.INNER_CONTACT内部联系人List<String>, InnerContactComponent
FormComponentType.DEPARTMENT部门List<String>, DepartmentComponent
FormComponentType.ADDRESS省市区AddressComponent
FormComponentType.PHOTO图片List<String>
FormComponentType.ATTACHMENT附件List<AttachmentComponent>
FormComponentType.TABLE明细(子表单)List<T>
FormComponentType.RELATE关联组件List<ListRelateComponent>
FormComponentType.CALCULATE计算公式String, BigDecimal
FormComponentType.STAR_RATING评分控件Integer, String
FormComponentType.PHONE电话控件String

复杂表单示例

import com.jeeapp.dingtalk.annotation.ProcessBean;
import com.jeeapp.dingtalk.annotation.FormComponent;
import com.jeeapp.dingtalk.request.workflow.FormComponentType;
import com.jeeapp.dingtalk.request.workflow.component.*;
import java.math.BigDecimal;
import java.util.List;

@ProcessBean(code = "PROC-REIMBURSEMENT", name = "报销申请")
public class ReimbursementProcessBean {

private String originatorUserId;
private Long originatorDeptId = -1L;

// 单行文本
@FormComponent(name = "申请人", type = FormComponentType.TEXT)
private String applicant;

// 部门组件
@FormComponent(name = "所属部门", type = FormComponentType.DEPARTMENT)
private DepartmentComponent department;

// 金额组件
@FormComponent(name = "报销金额", type = FormComponentType.MONEY)
private MoneyComponent amount;

// 日期组件
@FormComponent(name = "申请日期", type = FormComponentType.DATE, pattern = "yyyy-MM-dd")
private String applyDate;

// 附件组件
@FormComponent(name = "报销凭证", type = FormComponentType.ATTACHMENT)
private List<AttachmentComponent> attachments;

// 明细(子表单)
@FormComponent(name = "费用明细", type = FormComponentType.TABLE)
private List<ExpenseDetail> details;

// 多行文本
@FormComponent(name = "备注", type = FormComponentType.TEXTAREA)
private String remark;

// getters and setters
}

// 明细子表单
class ExpenseDetail {

@FormComponent(name = "费用类型", type = FormComponentType.SELECT)
private String expenseType;

@FormComponent(name = "费用金额", type = FormComponentType.MONEY)
private BigDecimal amount;

@FormComponent(name = "费用说明", type = FormComponentType.TEXT)
private String description;

// getters and setters
}

设置审批人

import com.jeeapp.dingtalk.request.workflow.ProcessInstanceBuilder;
import java.util.Arrays;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public String createProcessWithApprovers() throws Exception {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

LeaveProcessBean processBean = new LeaveProcessBean();
processBean.setOriginatorUserId("user123");
processBean.setApplicant("张三");
processBean.setReason("请假申请");

ProcessInstanceBuilder builder = workflowOps.fromProcessBean(processBean);

// 设置审批人 - 单人审批
builder.approver("NONE", Arrays.asList("manager001"));

// 设置审批人 - 会签(所有人都要审批)
builder.approver("AND", Arrays.asList("manager001", "manager002", "manager003"));

// 设置审批人 - 或签(任一人审批通过即可)
builder.approver("OR", Arrays.asList("manager001", "manager002"));

// 设置抄送人
builder.ccUsers(Arrays.asList("user001", "user002"))
.ccPosition("START"); // START: 开始时抄送, FINISH: 结束时抄送, START_FINISH: 开始和结束时都抄送

return builder.startV2();
}
}

上传附件

import com.jeeapp.dingtalk.WorkflowOperations;
import com.jeeapp.dingtalk.request.workflow.component.AttachmentComponent;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.List;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public String createProcessWithAttachment(MultipartFile file) throws Exception {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

// 上传附件
AttachmentComponent attachment = workflowOps.uploadAttachment(
"user123", // 上传人 userId
file.getOriginalFilename(), // 文件名
file.getBytes() // 文件内容
);

// 创建审批实例
ReimbursementProcessBean processBean = new ReimbursementProcessBean();
processBean.setOriginatorUserId("user123");
processBean.setApplicant("张三");

// 添加附件
List<AttachmentComponent> attachments = new ArrayList<>();
attachments.add(attachment);
processBean.setAttachments(attachments);

ProcessInstanceBuilder builder = workflowOps.fromProcessBean(processBean);
return builder.startV2();
}
}

查询审批实例

import com.jeeapp.dingtalk.WorkflowOperations;
import com.jeeapp.dingtalk.request.workflow.ProcessBeanWrapper;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public LeaveProcessBean getProcessInstance(String processInstanceId) throws Exception {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

// 使用新版 SDK 查询审批实例
ProcessBeanWrapper<LeaveProcessBean> wrapper = workflowOps.forProcessBeanAccessV2(
processInstanceId,
LeaveProcessBean.class
);

// 获取表单对象
LeaveProcessBean processBean = wrapper.getProcessBean();

// 获取审批状态
String status = wrapper.getStatus(); // NEW: 新创建, RUNNING: 审批中, TERMINATED: 终止, COMPLETED: 完成, CANCELED: 取消

// 获取审批结果
String result = wrapper.getResult(); // agree: 同意, refuse: 拒绝

// 获取流程实例 URL
String instanceUrl = workflowOps.getProcessInstanceUrl(processInstanceId);

System.out.println("审批状态: " + status);
System.out.println("审批结果: " + result);
System.out.println("审批详情: " + instanceUrl);

return processBean;
}
}

流程模板管理

import com.jeeapp.dingtalk.WorkflowOperations;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void manageProcessCodes() throws Exception {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

// 根据流程名称获取流程唯一码
String processCode = workflowOps.getProcessCode("请假申请");
System.out.println("流程唯一码: " + processCode);

// 根据流程唯一码获取流程名称
String processName = workflowOps.getProcessName("PROC-XXXXX");
System.out.println("流程名称: " + processName);

// 清除缓存
workflowOps.clearProcessCache("PROC-XXXXX", "请假申请");
}
}

ProcessInstanceBuilder 方法说明

方法名说明参数
processCode(String)设置流程唯一码流程模板唯一码
originatorUserId(String)设置发起人发起人 userId
deptId(Long)设置发起人部门部门 ID,根部门传 -1
agentId(Long)设置应用 ID应用 Agent ID(ISV 应用必填)
formComponentValue(String, String)添加单个表单组件值组件名称、组件值
formComponentValues(List)批量添加表单组件值组件值列表
approver(String, List)设置审批人审批类型(AND/OR/NONE)、审批人 userId 列表
targetSelectActioner(String, List)设置自选节点操作人节点规则 key、操作人 userId 列表
ccUser(String)添加单个抄送人抄送人 userId
ccUsers(List)批量添加抄送人抄送人 userId 列表
ccPosition(String)设置抄送时机START/FINISH/START_FINISH
start()使用旧版 SDK 发起审批返回审批实例 ID
startV2()使用新版 SDK 发起审批返回审批实例 ID

直接创建审批实例

不使用 @ProcessBean 注解,直接构建审批实例:

import com.jeeapp.dingtalk.DingTalkTemplate;
import com.jeeapp.dingtalk.request.workflow.ProcessInstanceBuilder;
import com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues;
import java.util.Arrays;

@Service
public class WorkflowService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public String createProcessDirectly() throws Exception {
ProcessInstanceBuilder builder = new ProcessInstanceBuilder(dingTalkTemplate, null);

String instanceId = builder
.processCode("PROC-XXXXX")
.originatorUserId("user123")
.deptId(-1L)
.formComponentValue("申请人", "张三")
.formComponentValue("请假类型", "年假")
.formComponentValue("请假天数", "3")
.formComponentValue("请假事由", "家中有事")
.ccUsers(Arrays.asList("user001", "user002"))
.ccPosition("START")
.startV2();

return instanceId;
}
}

钉钉 OA 审批表单组件转换器

钉钉 OA 审批的"表单 Bean ⇄ 审批组件值"双向转换由两层 SPI 完成:

  • FormComponentConverter(组件级):负责一类 钉钉审批组件(如 TEXT/MONEY/TABLE/ATTACHMENT 等)整体的读写。Starter 内置了 17 个实现(TextComponentConverterMoneyComponentConverterTableComponentConverter 等),可通过 Spring Bean 追加自定义实现。
  • ValueConverter<T>(字段级):在不替换组件级转换器的前提下,针对 单个字段 自定义"组件值 ⇄ Java 值"的转换;通过 @FormComponent(converter = ...) 在 Bean 字段上声明。

两者均会被 WorkflowOperationsdingTalkTemplate.workflowOperations())发起/读取审批实例时自动使用。

FormComponentConverter 接口
package com.jeeapp.dingtalk.request.workflow.convert;

public interface FormComponentConverter {

/** 旧版 SDK:把 Bean 字段写入审批组件值(read = Bean -> Request) */
OapiProcessinstanceCreateRequest.FormComponentValueVo read(
BeanWrapper beanWrapper, List<FormComponentProperty> properties);

/** 新版 SDK:把 Bean 字段写入审批组件值 */
StartProcessInstanceRequestFormComponentValues readV2(
BeanWrapper beanWrapper, List<FormComponentProperty> properties);

/** 旧版 SDK:把审批组件值回填到 Bean(write = Response -> Bean) */
boolean write(BeanWrapper beanWrapper, List<FormComponentProperty> properties,
FormComponentValueVo value);

/** 新版 SDK:把审批组件值回填到 Bean */
boolean writeV2(BeanWrapper beanWrapper, List<FormComponentProperty> properties,
GetProcessInstanceResponseBodyResultFormComponentValues value);
}

实际开发中通常继承 AbstractComponentConverter 即可,它已经处理了旧/新 SDK 之间的差异、@FormComponent#converter 的分发以及 @FormComponentExt 扩展值,子类只需要实现以下钩子:

方法说明
boolean supports(String componentType)判断当前转换器支持哪种 FormComponentType
OapiProcessinstanceCreateRequest.FormComponentValueVo readInternal(...)旧版 SDK:单字段写入逻辑(可选)
StartProcessInstanceRequestFormComponentValues readInternalV2(...)新版 SDK:单字段写入逻辑
void writeInternalV2(BeanWrapper, FormComponentProperty, GetProcessInstanceResponseBodyResultFormComponentValues)将组件值回填到字段

自定义示例:把钉钉自定义控件 MyCustomComponentcomponentTypeMyCustomField)映射为字符串。

import com.jeeapp.dingtalk.request.workflow.convert.AbstractComponentConverter;
import com.jeeapp.dingtalk.request.workflow.FormComponentProperty;
import com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues;
import com.aliyun.dingtalkworkflow_1_0.models.StartProcessInstanceRequest.StartProcessInstanceRequestFormComponentValues;
import org.springframework.beans.BeanWrapper;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(0) // 数值越小优先级越高(默认内置转换器在自定义之后)
public class MyCustomComponentConverter extends AbstractComponentConverter {

public static final String COMPONENT_TYPE = "MyCustomField";

@Override
protected boolean supports(String componentType) {
return COMPONENT_TYPE.equals(componentType);
}

@Override
protected StartProcessInstanceRequestFormComponentValues readInternalV2(
BeanWrapper beanWrapper, FormComponentProperty property) {
StartProcessInstanceRequestFormComponentValues v =
new StartProcessInstanceRequestFormComponentValues();
v.setId(property.getId());
v.setName(property.getName());
v.setComponentType(COMPONENT_TYPE);
v.setValue(String.valueOf(beanWrapper.getPropertyValue(property.getPropertyName())));
return v;
}

@Override
protected void writeInternalV2(BeanWrapper beanWrapper, FormComponentProperty property,
GetProcessInstanceResponseBodyResultFormComponentValues value) {
beanWrapper.setPropertyValue(property.getPropertyName(), value.getValue());
}
}

注册方式

只要把转换器声明为 Spring @Component/@BeanDingTalkAutoConfiguration 会自动通过 ObjectProvider<FormComponentConverter> 收集并注入到 DingTalkTemplate

@Bean
public DingTalkTemplateBuilder dingTalkTemplateBuilder(
ObjectProvider&lt;FormComponentConverter&gt; formComponentConverters, ...) &#123; ... &#125;

也可以在手动构建 DingTalkTemplate 时显式注册:

DingTalkTemplate template = new DingTalkTemplateBuilder()
.config(config)
.formComponentConverters(new MyCustomComponentConverter())
.build();
内置组件转换器

FormComponentType 与处理类的对应关系(见 ProcessBeanWrapperImpl#FormComponentConverters):

审批组件内置转换器
TEXTTextComponentConverter
TEXTAREATextareaComponentConverter
NUMBERNumberComponentConverter
MONEYMoneyComponentConverter
CALCULATECalculateComponentConverter
DATEDateComponentConverter
DATE_RANGEDateRangeComponentConverter
PHOTOPhotoComponentConverter
ATTACHMENTAttachmentComponentConverter
INNER_CONTACTInnerContactComponentConverter
DEPARTMENTDepartmentComponentConverter
RELATERelateComponentConverter
SELECTSelectComponentConverter
MULTI_SELECTMultiSelectComponentConverter
PHONEPhoneComponentConverter
STAR_RATINGStarRatingComponentConverter
ADDRESSAddressComponentConverter
TABLETableComponentConverter(递归复用上述转换器处理子表单)
备注

自定义 FormComponentConverter 会被插入到内置列表 之前,因此在 supports() 命中相同 componentType 时优先生效,不会破坏内置行为。

ValueConverter —— 字段级转换器

当组件类型本身没问题,但某个字段的 值表示 与默认行为不一致(例如把 JSON 字符串解析为 POJO、把枚举映射为标签文本),可以通过 ValueConverter<T> 在字段级别覆写。

接口非常轻量:

package com.jeeapp.dingtalk.request.workflow.convert;

public interface ValueConverter<T> {

/** Response -> Bean:把审批组件值转成 Java 值 */
T write(GetProcessInstanceResponseBodyResultFormComponentValues value);

/** Bean -> Request:把 Java 值转成提交给钉钉的 String */
String read(T value);
}

用法:在 @FormComponent / @FormComponentExt 上指定 converter

import com.jeeapp.dingtalk.annotation.FormComponent;
import com.jeeapp.dingtalk.annotation.ConvertTiming;
import com.jeeapp.dingtalk.request.workflow.FormComponentType;
import com.jeeapp.dingtalk.request.workflow.convert.ValueConverter;
import com.alibaba.fastjson.JSON;

@ProcessBean(code = "PROC-XXXXX", name = "请假申请")
public class LeaveProcessBean {

private String originatorUserId;

/** 单选下拉,钉钉端存放枚举 code,Bean 中以枚举对象使用 */
@FormComponent(name = "请假类型", type = FormComponentType.SELECT,
converter = LeaveTypeConverter.class)
private LeaveType leaveType;

/** 单行文本中存放 JSON,Bean 中以对象使用;只在写入审批 Bean 时执行 */
@FormComponent(name = "扩展信息", type = FormComponentType.TEXT,
when = ConvertTiming.WRITE,
converter = ExtraInfoConverter.class)
private ExtraInfo extra;
}

class LeaveTypeConverter implements ValueConverter<LeaveType> {
@Override
public LeaveType write(GetProcessInstanceResponseBodyResultFormComponentValues value) {
return value == null ? null : LeaveType.fromCode(value.getValue());
}
@Override
public String read(LeaveType value) {
return value == null ? null : value.getCode();
}
}

class ExtraInfoConverter implements ValueConverter<ExtraInfo> {
@Override
public ExtraInfo write(GetProcessInstanceResponseBodyResultFormComponentValues value) {
return value == null ? null : JSON.parseObject(value.getValue(), ExtraInfo.class);
}
@Override
public String read(ExtraInfo value) {
return value == null ? null : JSON.toJSONString(value);
}
}

@FormComponent#when 控制转换时机,可与 ValueConverter 组合:

ConvertTiming说明
ALWAYS(默认)读写都执行字段级转换
READ只在 Bean → Request(发起审批)时执行 ValueConverter#read
WRITE只在 Response → Bean(查询/回填审批)时执行 ValueConverter#write
注意

ValueConverter 通过 BeanUtils.instantiateClass(...) 反射实例化,要求实现类必须有 无参构造(无须声明为 Spring Bean)。如果转换逻辑需要访问 Spring Bean,请改用 FormComponentConverter + @Component 方式。

宜搭操作

钉钉宜搭是一个低代码开发平台,支持快速搭建表单、流程和应用。Starter 通过 @YidaForm / @YidaField 两个注解把宜搭表单与 Java Bean 绑定,并提供:

  • 数据双向映射FormBeanUtils 在 Bean 与宜搭 formData(写入 / 回填 / 子表单)之间互转。
  • 查询与排序:通过 @YidaFieldoperator / direction 自动生成 searchFieldJsondynamicOrder
  • 请求封装YidaOperations 把新版(dingtalkyida_2_0)和旧版(dingtalkyida_1_0)SDK 中常用的「表单数据 / 流程实例」接口统一封装为基于 @YidaForm Bean 的链式 Builder,自动注入 accessToken、限流、重试。
  • 代码生成yida-maven-plugin 在构建阶段拉取宜搭表单定义,自动生成与上述注解配套的 Java 实体类。

注解说明

@YidaForm

类级注解,声明 Bean 对应的宜搭应用与表单。也可由 yida-maven-plugin 自动生成。

属性类型说明
appTypeString宜搭应用 appType(形如 APP_XXXXXXXX
systemTokenString宜搭应用 systemToken
formUuidString表单 formUuid(形如 FORM-XXXXXXXX
formTypeString表单类型:receipt(普通表单) / process(流程表单)
import com.jeeapp.dingtalk.annotation.YidaForm;
import com.jeeapp.dingtalk.annotation.YidaField;

@YidaForm(
appType = "APP_XXXXXXXX",
systemToken = "OP_XXXXXXXX",
formUuid = "FORM-XXXXXXXX",
formType = "receipt"
)
public class EmployeeFormBean {
// ...
}
@YidaField

字段级注解,写入 / 回填 / 查询 / 排序四种用途统一在一个注解里

属性类型默认值说明
idString""宜搭组件的基础 key(设计器中字段标识,如 selectField_xxx),无需追加 _id / _value 后缀
primarybooleantrue是否绑定该组件的「primary 值」,详见下表
operatorOperatorNONE查询操作符;非 NONE 时该字段会出现在 searchFieldJson
parentIdString""子表单查询:父组件 ID(形如 tableField_orderDetails
directionDirectionNONE排序方向;非 NONE 时该字段会出现在 dynamicOrder

Operator 枚举(com.jeeapp.dingtalk.annotation.YidaField.Operator):

操作符说明适用组件
EQ等于文本、多行文本、数字、评分、日期、级联日期、单选、多选、复选框、单选框、级联选择、成员、部门
LIKE模糊匹配文本、多行文本、附件、图片
GT / GE / LT / LE / BETWEEN比较数字、评分、日期、级联日期
CONTAINS包含单选 / 多选 / 复选框 / 单选框 / 级联选择、成员、部门、子表单
NONE不参与查询(默认)-
备注

若字段类型与 operator 不匹配(如 TextField 使用 BETWEEN),生成查询条件时会抛出 UnsupportedOperationException

Direction 枚举(com.jeeapp.dingtalk.annotation.YidaField.Direction):

方向说明
ASC升序(symbol = +
DESC降序(symbol = -
NONE不参与排序(默认)
primary 与字段值的对应关系

部分组件在宜搭 formData 中会同时给出两个键,分别承载「唯一标识」与「显示文本」。框架在匹配时会同时判断 key startsWith property.id() 该 key 在该组件下的 primary 判定与注解 primary 一致,因此你只需要写组件的基础 key,再用 primary 切换取哪一份。

组件类型primary = true 取到primary = false 取到
SelectField / MultiSelectField / RadioField / CheckboxField / CascadeSelectField选项 ID(来自 xxx_id选项显示文本(来自 xxx
EmployeeField / DepartmentSelectField / CountrySelectField员工 / 部门 / 国家代码 ID(来自 xxx_id员工 / 部门 / 国家名称(来自 xxx
AssociationFormField关联实例对象 AssociationFormComponent(来自 xxx_id关联展示文本(来自 xxx
NumberField / RateField数值(来自 xxx_value格式化展示文本(来自 xxx
AddressField完整 AddressComponent 对象(来自无后缀 xxx地址文本字符串(来自 xxx_id
其他(TextField / TextareaField / EditorField / DateField / CascadeDateField / ImageField / AttachmentField / TableField / SerialNumberField / DigitalSignatureField / CcPhoneNumberField / CcChineseIdField该组件唯一的值(primary 取值不影响结果)同上

表单 Bean 示例

普通表单
import com.jeeapp.dingtalk.annotation.YidaField;
import com.jeeapp.dingtalk.annotation.YidaForm;
import com.jeeapp.dingtalk.request.yida.component.AddressComponent;
import com.jeeapp.dingtalk.request.yida.component.AssociationFormComponent;
import com.jeeapp.dingtalk.request.yida.component.AttachmentComponent;
import java.util.List;

@YidaForm(
appType = "APP_XXXXXXXX",
systemToken = "OP_XXXXXXXX",
formUuid = "FORM-XXXXXXXX",
formType = "receipt"
)
public class EmployeeFormBean {

// 单行文本
@YidaField(id = "textField_employeeName")
private String employeeName;

// 数字(默认 primary=true,取数值)
@YidaField(id = "numberField_age")
private Integer age;

// 数字(取格式化文本)
@YidaField(id = "numberField_age", primary = false)
private String ageText;

// 日期(毫秒时间戳)
@YidaField(id = "dateField_entryDate")
private Long entryDate;

// 日期区间
@YidaField(id = "cascadeDateField_period")
private List<Long> period;

// 下拉单选 - 取选项 ID
@YidaField(id = "selectField_department")
private String departmentId;

// 下拉单选 - 取选项显示文本
@YidaField(id = "selectField_department", primary = false)
private String departmentName;

// 下拉多选 - 取选项 ID 列表
@YidaField(id = "multiSelectField_skills")
private List<String> skillIds;

// 成员选择(多人)- 取员工 ID
@YidaField(id = "employeeField_teamMembers")
private List<String> teamMemberIds;

// 部门选择 - 取部门 ID
@YidaField(id = "departmentSelectField_dept")
private List<String> deptIds;

// 图片
@YidaField(id = "imageField_photo")
private List<AttachmentComponent> photos;

// 附件
@YidaField(id = "attachmentField_resume")
private List<AttachmentComponent> resumes;

// 地址(完整对象)
@YidaField(id = "addressField_addr")
private AddressComponent address;

// 关联表单
@YidaField(id = "associationFormField_company")
private AssociationFormComponent company;

// 子表单
@YidaField(id = "tableField_workExperience")
private List<WorkExperienceItem> workExperience;

// getters / setters
}

public class WorkExperienceItem {

@YidaField(id = "textField_company")
private String company;

@YidaField(id = "dateField_startDate")
private Long startDate;

@YidaField(id = "dateField_endDate")
private Long endDate;

// getters / setters
}
查询 Bean(同 Bean 复用 @YidaField

查询条件直接通过同一个 @YidaFieldoperator / parentId / direction 字段表达,不再使用单独的 @YidaCondition。Bean 上 @YidaForm 仍需声明(用于解析 appType / systemToken / formUuid)。

import com.jeeapp.dingtalk.annotation.YidaField;
import com.jeeapp.dingtalk.annotation.YidaField.Direction;
import com.jeeapp.dingtalk.annotation.YidaField.Operator;
import com.jeeapp.dingtalk.annotation.YidaForm;
import java.util.List;

@YidaForm(
appType = "APP_XXXXXXXX",
systemToken = "OP_XXXXXXXX",
formUuid = "FORM-XXXXXXXX",
formType = "receipt"
)
public class EmployeeSearchBean {

// 精确匹配
@YidaField(id = "textField_employeeName", operator = Operator.EQ)
private String employeeName;

// 模糊匹配
@YidaField(id = "textareaField_description", operator = Operator.LIKE)
private String description;

// 范围匹配
@YidaField(id = "numberField_age", operator = Operator.GE)
private Integer minAge;

@YidaField(id = "numberField_age", operator = Operator.LE)
private Integer maxAge;

// 区间
@YidaField(id = "dateField_entryDate", operator = Operator.BETWEEN)
private List<Long> entryDateRange;

// 包含(多选 / 部门)
@YidaField(id = "departmentSelectField_dept", operator = Operator.CONTAINS)
private List<String> departmentIds;

// 子表单查询:通过 parentId 指向父子表单组件
@YidaField(
id = "textField_productName",
operator = Operator.LIKE,
parentId = "tableField_orderDetails"
)
private String productName;

// 排序:入职日期降序
@YidaField(id = "dateField_entryDate", direction = Direction.DESC)
private Object entryDateOrder;

// getters / setters
}
备注

排序字段在 Bean 中只用于声明排序意图,字段本身的值不参与排序,因此可以使用任意类型(习惯写为 Object 或与原字段类型相同)。

FormBeanUtils —— 表单数据转换

com.jeeapp.dingtalk.request.yida.FormBeanUtils 提供 Bean ⇄ 宜搭 formData 的双向转换,以及查询条件 / 排序 JSON 的生成。

方法说明
toFormData(target)Bean → Map<String, ?>(写入 / 更新使用)
toFormDataJson(target)Bean → formDataJson(直接发送给宜搭 SDK)
fromFormData(formData, targetClass)Map<String, ?> → Bean(回填查询结果)
toSearchConditionJson(target)根据 @YidaField#operator 生成 searchFieldJson
toOrderConfigJson(target)根据 @YidaField#direction 生成 dynamicOrder
getFormDefinition(targetClass)解析 @YidaForm 元信息
import com.jeeapp.dingtalk.request.yida.FormBeanUtils;

EmployeeFormBean bean = new EmployeeFormBean();
bean.setEmployeeName("张三");
bean.setAge(28);

String formDataJson = FormBeanUtils.toFormDataJson(bean);

// 从查询结果中恢复
EmployeeFormBean restored = FormBeanUtils.fromFormData(map, EmployeeFormBean.class);

toSearchConditionJson 输出示例:

[
{"key":"textField_employeeName","value":"张三","operator":"eq","componentName":"TextField","parentId":""},
{"key":"numberField_age","value":25,"operator":"ge","componentName":"NumberField","parentId":""},
{"key":"departmentSelectField_dept","value":["dept001","dept002"],"operator":"contains","componentName":"DepartmentSelectField","parentId":""}
]

toOrderConfigJson 输出示例:

[{"dateField_entryDate":"-"}]

YidaOperations —— 表单与流程实例操作

通过 dingTalkTemplate.yidaOperations() 获取实例:

@Autowired
private DingTalkTemplate dingTalkTemplate;

YidaOperations yida = dingTalkTemplate.yidaOperations();
入参约定

每个方法返回一个 FormRequestBuilder 子类,链式调用 customize(Consumer<REQ>) 可追加 SDK 原生字段(userIdpageNumberpageSizeoriginatorUserIdformInstanceId 等),最后调用 execute() 触发请求。模板会自动从 @YidaForm 解析出 appType / systemToken / formUuid 并注入 accessToken,按 dingtalk.retry.* / dingtalk.rate-limits 进行限流与重试。

形参类型适用场景框架行为
T target(Bean 实例写入 / 更新 / 发起流程 / 搜索写入类自动 FormBeanUtils.toFormDataJson(target);搜索类自动注入 searchFieldJsondynamicOrder
Class<T>读取 / 删除 / 列表等不需要 Bean 内容的接口仅解析 @YidaForm 元信息,业务字段通过 customize(...) 设置
List<T> / Map<String, T>批量写入自动逐项序列化为 formDataJsonList / updateFormDataJsonMap
表单数据(V2,dingtalkyida_2_0
方法对应钉钉接口
saveFormDataV2(T target)SaveFormData —— 新建一条表单数据
updateFormDataV2(T target)UpdateFormData —— 更新(在 customizesetFormInstanceId(...)
createOrUpdateFormDataV2(T target)CreateOrUpdateFormData —— 不存在则创建,存在则更新
getFormDataByIDV2(Class<T>).id(formInstanceId)GetFormDataByID —— 按表单实例 ID 查询
searchFormDatasV2(T target)SearchFormDatas —— 多条件搜索(自动生成 searchFieldJson + dynamicOrder
searchFormDataSecondGenerationV2(T target)SearchFormDataSecondGeneration —— 二代搜索接口
import com.aliyun.dingtalkyida_2_0.models.GetFormDataByIDResponse;
import com.aliyun.dingtalkyida_2_0.models.SaveFormDataResponse;
import com.aliyun.dingtalkyida_2_0.models.SearchFormDatasResponse;
import com.jeeapp.dingtalk.request.yida.FormBeanUtils;

@Service
public class EmployeeService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

/** 新建:传入领域 Bean,自动序列化为 formDataJson */
public String create(EmployeeFormBean bean) throws Exception {
SaveFormDataResponse resp = dingTalkTemplate.yidaOperations()
.saveFormDataV2(bean)
.customize(req -> req.setUserId("user001"))
.execute();
return resp.getBody().getResult(); // formInstanceId
}

/** 按 ID 查询:使用 builder 上的 .id(...) 传入实例 ID */
public EmployeeFormBean get(String formInstanceId) throws Exception {
GetFormDataByIDResponse resp = dingTalkTemplate.yidaOperations()
.getFormDataByIDV2(EmployeeFormBean.class)
.id(formInstanceId)
.customize(req -> req.setUserId("user001"))
.execute();
return FormBeanUtils.fromFormData(resp.getBody().getFormData(), EmployeeFormBean.class);
}

/** 搜索:传入查询 Bean,框架自动从 @YidaField 生成 searchFieldJson 与 dynamicOrder */
public List<EmployeeFormBean> search(EmployeeSearchBean condition) throws Exception {
SearchFormDatasResponse resp = dingTalkTemplate.yidaOperations()
.searchFormDatasV2(condition)
.customize(req -> req
.setPageNumber(1L)
.setPageSize(50L)
.setUserId("user001"))
.execute();
return resp.getBody().getData().stream()
.map(it -> FormBeanUtils.fromFormData(it, EmployeeFormBean.class))
.collect(Collectors.toList());
}

/** 更新:通过 customize 在请求体里指定 formInstanceId */
public void update(String formInstanceId, EmployeeFormBean bean) throws Exception {
dingTalkTemplate.yidaOperations()
.updateFormDataV2(bean)
.customize(req -> req
.setUserId("user001")
.setFormInstanceId(formInstanceId))
.execute();
}
}
流程实例(V2)

针对 formType = process 的流程表单,提供发起 / 查询审批实例的 Builder:

方法对应钉钉接口
startInstanceV2(T target)StartInstance —— 发起宜搭流程实例
getInstanceByIdV2(Class<T>).id(processInstanceId)GetInstanceById —— 按实例 ID 查询
getInstanceIdListV2(T target)GetInstanceIdList —— 按条件分页获取实例 ID 列表
getInstancesV2(T target)GetInstances —— 按条件分页获取实例详情
import com.aliyun.dingtalkyida_2_0.models.StartInstanceResponse;

public String startLeaveProcess(LeaveFormBean bean) throws Exception {
StartInstanceResponse resp = dingTalkTemplate.yidaOperations()
.startInstanceV2(bean)
.customize(req -> req.setOriginatorUserId(bean.getOriginatorUserId()))
.execute();
return resp.getBody().getResult(); // processInstanceId
}
批量与扩展接口(V1,dingtalkyida_1_0

部分高级能力仅在旧版 SDK 上提供,YidaOperations 同样进行了封装:

方法说明
batchGetFormDataByIdList(Class<T>)批量按 ID 列表查询表单数据
batchSaveFormData(List<T>)批量新增,自动构建 formDataJsonList
batchUpdateFormDataByInstanceId(T target)批量更新(按表单实例 ID)
batchUpdateFormDataByInstanceMap(Map<String, T>)批量更新(按 formInstanceId → Bean 映射)
deleteFormData(Class<T>)删除单条表单数据
batchRemovalByFormInstanceIdList(Class<T>)批量逻辑删除
searchFormDataRemovalTableData(T target)搜索回收站中的表单数据(用查询 Bean 表达条件)
searchFormDataSecondGenerationNoTableField(T target)二代搜索(不含子表单字段)
deleteInstance(Class<T>) / terminateInstance(Class<T>)删除 / 终止流程实例
executeTask(T target)执行流程任务
getFormListInApp(Class<T>)查询应用下的表单列表
getInstancesByIdList(Class<T>)按实例 ID 列表查询
import com.aliyun.dingtalkyida_1_0.models.BatchSaveFormDataResponse;
import com.aliyun.dingtalkyida_1_0.models.DeleteFormDataResponse;

/** 批量新建:传入实例 List,自动构造 formDataJsonList */
public List<String> batchCreate(List<EmployeeFormBean> beans) throws Exception {
BatchSaveFormDataResponse resp = dingTalkTemplate.yidaOperations()
.batchSaveFormData(beans)
.customize(req -> req.setNoExecuteExpression(true))
.execute();
return resp.getBody().getResult();
}

/** 删除:仅需 Class<T>,业务字段通过 customize 设置 */
public void delete(String formInstanceId) throws Exception {
DeleteFormDataResponse resp = dingTalkTemplate.yidaOperations()
.deleteFormData(EmployeeFormBean.class)
.customize(req -> req
.setFormInstanceId(formInstanceId)
.setUserId("user001"))
.execute();
log.info("删除结果: {}", resp.getBody().getResult());
}

Yida Maven Plugin(代码生成)

yida-maven-plugin 是一个 Maven 插件,可在构建阶段连接钉钉宜搭 OpenAPI 拉取表单字段定义,自动生成与 @YidaField@YidaForm 配套使用的 Java 实体类,避免手工编写大量重复的 Bean。

主要功能

  • 通过钉钉宜搭 OpenAPI 拉取表单与字段定义
  • 根据宜搭组件类型自动生成对应的 Java 字段(含 @YidaField 注解)
  • 在生成的类上自动添加 @YidaForm 注解,包含 appTypesystemTokenformUuidformType 等元信息
  • 自动将输出目录注册为编译源路径(addCompileSourceRoot),无需手动配置 build-helper-maven-plugin
  • 支持 skip 跳过执行、debug 输出详细日志
插件坐标
<plugin>
<groupId>com.jeeapp.spring.boot</groupId>
<artifactId>yida-maven-plugin</artifactId>
<!-- version 由 jeeapp-spring-boot-dependencies 统一管理,可省略 -->
</plugin>
目标(Goal)
目标生命周期阶段说明
yida:generategenerate-sources根据宜搭表单定义生成 Java 实体类

执行命令:

# 单独执行生成
mvn yida:generate

# 在构建过程中自动执行(已绑定 generate-sources 阶段)
mvn clean compile
配置参数
参数必填默认值说明
corpId-钉钉企业 corpId
unifiedAppId-钉钉统一应用 ID
clientId-应用 Key(Client ID)
clientSecret-应用 Secret(Client Secret)
agentId-企业内部应用 AgentId
userId-调用宜搭 OpenAPI 的用户 userId(需为宜搭管理员或具备相应权限)
apps-宜搭应用列表,详见下文
outputDirectory${project.build.directory}/generated-sources/生成的 Java 源码输出目录
packageName${project.groupId}.yida生成类的默认包名(可被 app.packageName 覆盖)
skipfalse是否跳过执行
debugfalse是否打印 SDK 调试日志(false 时屏蔽 com.jeeapp / com.aliyun 的日志输出)

apps 元素结构

属性必填说明
appType宜搭应用 appType(形如 APP_XXXXXXXX),为空将被过滤
systemToken宜搭应用 systemToken,为空将被过滤
packageName该应用生成类使用的包名,覆盖插件级 packageName
forms需要生成的表单列表

forms 元素结构

属性必填说明
formUuid宜搭表单的 formUuid(形如 FORM-XXXXXXXX
className生成的 Java 类名(不含包名)。formTypetitle 由插件运行时从宜搭接口获取,无需配置
完整示例
<build>
<plugins>
<plugin>
<groupId>com.jeeapp.spring.boot</groupId>
<artifactId>yida-maven-plugin</artifactId>
<executions>
<execution>
<id>generate-yida-sources</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<!-- 钉钉企业及应用配置 -->
<corpId>ding_xxxxxxxx</corpId>
<unifiedAppId>your_unified_app_id</unifiedAppId>
<clientId>dingxxxxxxxx</clientId>
<clientSecret>your_client_secret</clientSecret>
<agentId>1234567890</agentId>
<userId>your_admin_user_id</userId>

<!-- 输出与包名(可选) -->
<outputDirectory>${project.build.directory}/generated-sources/yida</outputDirectory>
<packageName>com.example.yida</packageName>
<debug>false</debug>
<skip>false</skip>

<!-- 宜搭应用列表 -->
<apps>
<app>
<appType>APP_WDFQZM81UZWTTEX0Y7F2</appType>
<systemToken>OP866FB1V9TVUC0VAW1XQ6GM738D33PIAL7BMJF</systemToken>
<!-- 可选:覆盖默认包名 -->
<packageName>com.example.yida.workspace</packageName>
<forms>
<form>
<formUuid>FORM-A4058EA5DE4D49D2AC12B3BFDA852C23CFZ8</formUuid>
<className>WorkStation</className>
</form>
</forms>
</app>
</apps>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

执行 mvn clean compile 后,将在 outputDirectory/<package>/ 下生成形如:

package com.example.yida.workspace;

import com.jeeapp.dingtalk.annotation.YidaField;
import com.jeeapp.dingtalk.annotation.YidaForm;

/**
* 工位
*/
@YidaForm(
appType = "APP_WDFQZM81UZWTTEX0Y7F2",
systemToken = "OP866FB1V9TVUC0VAW1XQ6GM738D33PIAL7BMJF",
formUuid = "FORM-A4058EA5DE4D49D2AC12B3BFDA852C23CFZ8",
formType = "receipt"
)
public class WorkStation {

@YidaField(id = "textField_xxx")
private String stationName;

// ... 其他字段由宜搭组件定义自动生成
}
组件类型映射

插件内置 24 种宜搭组件的字段处理器(*FieldHandler),自动将组件类型映射为合适的 Java 类型:

宜搭组件Java 类型Handler
单行文本 (TEXT)StringTextFieldHandler
多行文本 (TEXTAREA)StringTextFieldHandler
富文本 (EDITOR)StringTextFieldHandler
流水号 (SERIAL_NUMBER)StringTextFieldHandler
身份证 (CC_CHINESE_ID_FIELD)StringTextFieldHandler
手机号 (CC_PHONE_NUMBER)StringTextFieldHandler
手写签名 (DIGITAL_SIGNATURE)StringTextFieldHandler
数字 (NUMBER)BigDecimal / Long(按精度自动选择)NumberFieldHandler
评分 (RATE)LongRateFieldHandler
下拉单选 (SELECT)StringSelectFieldHandler
单选框 (RADIO)StringSelectFieldHandler
下拉多选 (MULTI_SELECT)List<String>MultiSelectFieldHandler
复选框 (CHECKBOX)List<String>MultiSelectFieldHandler
级联选择 (CASCADE_SELECT)List<String>CascadeSelectFieldHandler
国家/地区 (COUNTRY_SELECT)String / List<String>CountrySelectFieldHandler
日期 (DATE)DateDateFieldHandler
级联日期 (CASCADE_DATE)List<Date>CascadeDateFieldHandler
成员选择 (EMPLOYEE)String / List<String>EmployeeFieldHandler
部门选择 (DEPARTMENT_SELECT)Long / List<Long>DepartmentSelectFieldHandler
图片 (IMAGE)String / List<AttachmentComponent>AttachmentFieldHandler
附件 (ATTACHMENT)String / List<AttachmentComponent>AttachmentFieldHandler
地址 (ADDRESS)AddressComponentAddressFieldHandler
关联表单 (ASSOCIATION_FORM)String / List<AssociationFormComponent>AssociationFormFieldHandler
子表单 (TABLE)List<InnerClass>(生成内部静态类)TableFieldHandler
备注

是否使用单值或集合类型取决于宜搭组件的「单选 / 多选」配置;子表单会自动生成嵌套的 public static 内部类。

注意事项
  1. 凭证安全clientSecretsystemToken 等属于敏感信息,建议通过 Maven properties-D 命令行参数或 CI 密文变量注入,避免直接提交到代码仓库。
  2. 权限要求userId 必须具备宜搭对应应用的访问权限,且应用需开通宜搭 OpenAPI(dingtalkyida_1_0 / dingtalkyida_2_0)。
  3. 应用类型:当前插件内部以 DingTalkAppType.CORP(企业内部应用)方式构建 DingTalkTemplate,因此 agentId 必填。
  4. 生成路径:插件会调用 MavenProject#addCompileSourceRootoutputDirectory 加入编译源路径,无需额外配置 IDE 即可识别生成的类(部分 IDE 需要重新导入项目)。
  5. 过滤规则:仅处理同时配置了 appTypesystemTokenapp;只生成 alias(字段别名)非空的字段。
  6. 与运行时配合:生成的实体类依赖 dingtalk-spring-boot-starter 提供的注解和组件类型(AttachmentComponentAddressComponentAssociationFormComponent 等),运行时同样需要引入该 starter。

消息操作

钉钉支持三种消息发送方式:工作通知消息、自定义机器人群消息和批量单聊消息。

工作通知消息

工作通知消息是以某个应用的名义推送到员工的工作消息,支持多种消息类型。

使用限制:

  • 企业内部应用单次最多给 5000 人发送,第三方应用最多给 1000 人发送
  • 给同一员工一天只能发送一条内容相同的消息
  • 企业内部应用每天给每个员工最多可发送 500 条消息,第三方应用最多 50 条
  • 每分钟最多有 5000 人可以接收到消息
  • 通过 toAllUser() 全员推送消息,一天最多 3 次

文本消息

import com.jeeapp.dingtalk.DingTalkTemplate;
import com.jeeapp.dingtalk.MessageOperations;

@Service
public class MessageService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void sendTextMessage() throws Exception {
MessageOperations messageOps = dingTalkTemplate.messageOperations();

Long taskId = messageOps.createCorpConversationMessage()
.text()
.content("这是一条工作通知消息")
.msg()
.userId("user001") // 单个接收人
.userIds(Arrays.asList("user002", "user003")) // 批量接收人
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

System.out.println("消息发送任务ID: " + taskId);
}

// 发送给所有人
public void sendToAll() throws Exception {
messageOps.createCorpConversationMessage()
.text()
.content("全员通知消息")
.msg()
.toAllUser() // 发送给所有人
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();
}

// 发送给部门
public void sendToDept() throws Exception {
messageOps.createCorpConversationMessage()
.text()
.content("部门通知消息")
.msg()
.deptId(123L) // 部门ID
.deptIds(Arrays.asList(456L, 789L)) // 多个部门
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();
}
}

链接消息

messageOps.createCorpConversationMessage()
.link()
.title("钉钉开放平台")
.text("钉钉开放平台是一个连接企业应用和钉钉用户的平台")
.picUrl("https://img.alicdn.com/tfs/TB1XXXXXXXXXXXaXpXX.png")
.messageUrl("https://open.dingtalk.com")
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

Markdown 消息

String markdown = """
# 一级标题
## 二级标题

**加粗文本**
*斜体文本*

- 列表项1
- 列表项2

[链接文本](https://www.dingtalk.com)

![图片](https://img.alicdn.com/xxx.png)
""";

messageOps.createCorpConversationMessage()
.markdown()
.title("Markdown 消息")
.text(markdown)
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

ActionCard 卡片消息

// 整体跳转 ActionCard
messageOps.createCorpConversationMessage()
.actionCard()
.title("重要通知")
.markdown("### 系统升级通知\n\n系统将于今晚 22:00 进行升级维护")
.singleTitle("查看详情")
.singleUrl("https://www.dingtalk.com")
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

// 独立跳转 ActionCard(多按钮)
messageOps.createCorpConversationMessage()
.actionCard()
.title("审批通知")
.markdown("### 您有一个新的审批单\n\n申请人:张三\n申请事由:请假")
.btnOrientation("0") // 0-竖直排列, 1-横向排列
.btnJson("同意", "https://www.dingtalk.com/approve?action=agree")
.btnJson("拒绝", "https://www.dingtalk.com/approve?action=reject")
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

OA 消息

messageOps.createCorpConversationMessage()
.oa()
.messageUrl("https://www.dingtalk.com")
.pcMessageUrl("https://www.dingtalk.com")
.head()
.text("请假申请")
.bgColor("FFBBBBBB")
.end()
.body()
.title("张三的请假申请")
.form("申请人", "张三")
.form("请假类型", "年假")
.form("开始时间", "2024-01-01")
.form("结束时间", "2024-01-03")
.form("请假天数", "3天")
.content("家里有事需要请假三天")
.rich("3", "天")
.end()
.statusBar("FF0087FF", "待审批")
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

图片、文件、语音消息

// 图片消息
messageOps.createCorpConversationMessage()
.image()
.mediaId("@media_id_xxx") // 通过媒体文件接口上传获取
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

// 文件消息
messageOps.createCorpConversationMessage()
.file()
.mediaId("@media_id_xxx") // 最大 10MB
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

// 语音消息
messageOps.createCorpConversationMessage()
.voice()
.mediaId("@media_id_xxx")
.duration("60") // 时长(秒),小于 60
.msg()
.userId("user001")
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.send();

自定义机器人群消息

自定义机器人支持在企业内部群和普通钉钉群内发送群消息,不支持发送单聊消息。

使用限制:

  • 每个机器人每分钟最多发送 20 条消息到群里
  • 如果超过 20 条,会限流 10 分钟

配置机器人

首先需要在钉钉群中添加自定义机器人,获取 Webhook 地址和密钥。

文本消息

@Service
public class RobotMessageService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void sendRobotTextMessage() throws Exception {
MessageOperations messageOps = dingTalkTemplate.messageOperations();

messageOps.createCustomRobotMessage()
.text()
.content("这是一条机器人消息")
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.secret("SEC...") // 签名密钥(可选)
.atUserId("user001") // @某人
.atMobile("138xxxxxxxx") // 通过手机号@某人
.atAll() // @所有人
.send();
}
}

链接消息

messageOps.createCustomRobotMessage()
.link()
.title("钉钉开放平台")
.text("钉钉开放平台介绍")
.messageUrl("https://open.dingtalk.com")
.picUrl("https://img.alicdn.com/xxx.png")
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.send();

Markdown 消息

String markdown = """
# 系统监控告警

**告警时间**:2024-01-01 10:00:00

**告警内容**:
- CPU 使用率:95%
- 内存使用率:88%
- 磁盘使用率:75%

[查看详情](https://monitor.example.com)
""";

messageOps.createCustomRobotMessage()
.markdown()
.title("系统告警")
.text(markdown)
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.atUserId("admin001")
.send();

ActionCard 消息

// 整体跳转 ActionCard
messageOps.createCustomRobotMessage()
.actionCard()
.title("代码审查通知")
.markdown("### 代码审查请求\n\n**提交人**:张三\n**分支**:feature/xxx\n**变更文件**:5个")
.singleTitle("去审查")
.singleUrl("https://github.com/xxx")
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.send();

// 独立跳转 ActionCard
messageOps.createCustomRobotMessage()
.actionCard()
.title("构建结果")
.markdown("### 构建失败\n\n**项目**:project-name\n**分支**:master")
.btnOrientation("1") // 横向排列
.btnJson("查看日志", "https://ci.example.com/log")
.btnJson("重新构建", "https://ci.example.com/rebuild")
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.send();

FeedCard 消息

messageOps.createCustomRobotMessage()
.feedCard()
.link("新闻标题1", "https://news.example.com/1", "https://img.example.com/1.jpg")
.link("新闻标题2", "https://news.example.com/2", "https://img.example.com/2.jpg")
.link("新闻标题3", "https://news.example.com/3", "https://img.example.com/3.jpg")
.msg()
.webhookUrl("https://oapi.dingtalk.com/robot/send?access_token=xxx")
.send();

批量单聊消息

企业机器人可以批量发送单聊消息给指定用户。

@Service
public class BatchMessageService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void sendBatchMessage() throws Exception {
MessageOperations messageOps = dingTalkTemplate.messageOperations();

// 注意:需要先创建 RobotBatchMessageBuilder
// 具体实现请参考 RobotBatchMessageBuilder 源码
}
}

卡片操作

钉钉互动卡片是一种富交互的消息形式,支持按钮点击、表单提交等交互操作。

创建卡片

钉钉支持多种场景的卡片投放:单聊、群聊、机器人、文档、待办等。

单聊场景卡片

import com.jeeapp.dingtalk.DingTalkTemplate;
import com.jeeapp.dingtalk.CardOperations;

@Service
public class CardService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void createImSingleCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

String outTrackId = "card-" + System.currentTimeMillis();
String cardTemplateId = "your_card_template_id"; // 卡片模板ID

cardOps.createCard()
.imSingleModel()
// 投放配置
.deliver()
.robotCode("dingXXXXXXXX") // 机器人编码
.userIds(Arrays.asList("user001", "user002")) // 接收人
.end()
// 开放数据配置
.openSpace()
.outTrackId(outTrackId)
.cardTemplateId(cardTemplateId)
.cardData() // 卡片数据
.put("title", "待办提醒")
.put("content", "您有一个新的待办任务")
.put("deadline", "2024-01-01 18:00")
.end()
.end()
.end()
.build()
.createAndDeliver();
}
}

群聊场景卡片

public void createImGroupCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.createCard()
.imGroupModel()
.deliver()
.robotCode("dingXXXXXXXX")
.openConversationIds(Arrays.asList("cidXXXXXXXX")) // 群会话ID
.end()
.openSpace()
.outTrackId("card-" + System.currentTimeMillis())
.cardTemplateId("card_template_id")
.cardData()
.put("title", "群公告")
.put("content", "本周五下午团建活动")
.end()
.atUserIds(Arrays.asList("user001", "user002")) // @某人
.end()
.end()
.build()
.createAndDeliver();
}

机器人场景卡片

public void createImRobotCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.createCard()
.imRobotModel()
.deliver()
.robotCode("dingXXXXXXXX")
.spaceType("IM_ROBOT")
.end()
.openSpace()
.outTrackId("card-" + System.currentTimeMillis())
.cardTemplateId("card_template_id")
.cardData()
.put("title", "机器人通知")
.put("content", "系统升级完成")
.end()
.end()
.end()
.build()
.createAndDeliver();
}

待办场景卡片

public void createTopCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.createCard()
.topModel()
.deliver()
.userIds(Arrays.asList("user001"))
.end()
.openSpace()
.outTrackId("card-" + System.currentTimeMillis())
.cardTemplateId("card_template_id")
.cardData()
.put("title", "待办任务")
.put("content", "完成代码审查")
.put("priority", "high")
.put("deadline", "2024-01-01")
.end()
.end()
.end()
.build()
.createAndDeliver();
}

文档场景卡片

public void createDocCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.createCard()
.docModel()
.deliver()
.docKey("doc_key_xxx") // 文档key
.end()
.outTrackId("card-" + System.currentTimeMillis())
.cardTemplateId("card_template_id")
.cardData()
.put("title", "文档评论")
.put("content", "有人评论了你的文档")
.end()
.end()
.build()
.createAndDeliver();
}

酷应用场景卡片

public void createCoFeedCard() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.createCard()
.coFeedModel()
.deliver()
.coFeedOpenSpaceId("space_id_xxx")
.end()
.openSpace()
.outTrackId("card-" + System.currentTimeMillis())
.cardTemplateId("card_template_id")
.cardData()
.put("title", "酷应用通知")
.put("content", "数据已更新")
.end()
.end()
.end()
.build()
.createAndDeliver();
}

更新卡片

卡片创建后可以动态更新卡片内容。

更新公共数据

@Service
public class CardService {

@Autowired
private DingTalkTemplate dingTalkTemplate;

public void updateCardData() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

String outTrackId = "card-123456"; // 卡片的 outTrackId

// 全量更新
Boolean result = cardOps.updateCard()
.outTrackId(outTrackId)
.updateCardDataByKey(false) // false-全量更新, true-增量更新
.cardData("title", "新标题")
.cardData("content", "更新后的内容")
.cardData("status", "已完成")
.update();

System.out.println("更新结果: " + result);
}

// 增量更新(只更新指定字段)
public void updateCardDataByKey() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.updateCard()
.outTrackId("card-123456")
.updateCardDataByKey(true) // 增量更新
.cardData("status", "处理中") // 只更新 status 字段
.update();
}
}

更新私有数据

私有数据是针对特定用户的个性化数据,不同用户看到的内容不同。

public void updatePrivateData() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

// 为不同用户设置不同的私有数据
cardOps.updateCard()
.outTrackId("card-123456")
.updatePrivateDataByKey(true)
.userIdType(1) // 1-userId, 2-unionId
// 用户1看到的内容
.privateData("user001", "taskStatus", "待处理")
.privateData("user001", "priority", "高")
// 用户2看到的内容
.privateData("user002", "taskStatus", "已完成")
.privateData("user002", "priority", "低")
.update();
}

// 批量设置私有数据
public void updatePrivateDataBatch() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

Map<String, String> user1Data = new HashMap<>();
user1Data.put("field1", "value1");
user1Data.put("field2", "value2");

Map<String, String> user2Data = new HashMap<>();
user2Data.put("field1", "value3");
user2Data.put("field2", "value4");

cardOps.updateCard()
.outTrackId("card-123456")
.privateData("user001", user1Data)
.privateData("user002", user2Data)
.update();
}

同时更新公共数据和私有数据

public void updateCardAndPrivateData() throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

cardOps.updateCard()
.outTrackId("card-123456")
// 更新公共数据
.updateCardDataByKey(true)
.cardData("title", "新标题")
.cardData("updateTime", LocalDateTime.now().toString())
// 更新私有数据
.updatePrivateDataByKey(true)
.privateData("user001", "viewStatus", "已读")
.privateData("user002", "viewStatus", "未读")
.update();
}

安全更新(捕获异常)

public void updateCardSafely() {
CardOperations cardOps = dingTalkTemplate.cardOperations();

// updateSafely() 方法会捕获异常并返回 false
Boolean result = cardOps.updateCard()
.outTrackId("card-123456")
.cardData("status", "已完成")
.updateSafely();

if (result) {
System.out.println("卡片更新成功");
} else {
System.out.println("卡片更新失败");
}
}

卡片最佳实践

1. 使用唯一的 outTrackId

// 推荐:使用业务ID + 时间戳
String outTrackId = "order_" + orderId + "_" + System.currentTimeMillis();

// 推荐:使用 UUID
String outTrackId = "card_" + UUID.randomUUID().toString();

2. 卡片数据结构设计

// 设计清晰的数据结构
Map<String, String> cardData = new HashMap<>();
cardData.put("type", "approval"); // 卡片类型
cardData.put("businessId", "12345"); // 业务ID
cardData.put("title", "请假申请");
cardData.put("applicant", "张三");
cardData.put("status", "pending");
cardData.put("createTime", "2024-01-01 10:00:00");

cardOps.createCard()
.imSingleModel()
.openSpace()
.outTrackId(outTrackId)
.cardTemplateId(cardTemplateId)
.cardData(cardData)
.end()
.end()
.build()
.createAndDeliver();

3. 错误处理

try {
cardOps.createCard()
// ... 卡片配置
.build()
.createAndDeliver();
} catch (Exception e) {
log.error("创建卡片失败: outTrackId={}", outTrackId, e);
// 发送告警或降级处理
}

4. 卡片状态管理

@Service
public class CardStateService {

// 保存卡片状态到数据库
public void saveCardState(String outTrackId, String status) {
// 保存到数据库
}

// 更新卡片状态
public void updateCardStatus(String outTrackId, String newStatus) throws Exception {
CardOperations cardOps = dingTalkTemplate.cardOperations();

// 更新卡片显示
cardOps.updateCard()
.outTrackId(outTrackId)
.cardData("status", newStatus)
.cardData("updateTime", LocalDateTime.now().toString())
.update();

// 更新数据库
saveCardState(outTrackId, newStatus);
}
}

事件处理

钉钉支持通过 HTTP 回调或 Stream 模式接收事件通知。

支持的事件类型

钉钉支持多种事件类型,主要分为以下几类:

组织架构事件

事件类型说明事件类
user_add_org通讯录用户增加OrgUserAddEvent
user_modify_org通讯录用户更改OrgUserModifyEvent
user_active_org用户激活OrgUserActiveEvent
user_leave_org通讯录用户离职OrgUserLeaveEvent
org_dept_create部门创建OrgDeptCreateEvent
org_dept_modify部门修改OrgDeptModifyEvent
org_dept_remove部门删除OrgDeptRemoveEvent
org_admin_add用户被设为管理员OrgAdminAddEvent
org_admin_remove用户被取消管理员OrgAdminRemoveEvent
label_user_change员工角色信息变更OrgUserRoleChangeEvent
label_conf_add增加角色或角色组OrgRoleAddEvent
label_conf_del删除角色或角色组OrgRoleDelEvent
label_conf_modify修改角色或角色组OrgRoleModifyEvent

审批流程事件

事件类型说明事件类
bpms_task_change审批任务变更ProcessTaskEvent
bpms_instance_change审批实例变更ProcessInstanceEvent
workflow_form_change审批模板状态变更WorkflowFormChangeEvent

考勤事件

事件类型说明事件类
check_in用户签到CheckInEvent
attendance_check_record员工打卡AttendanceCheckRecordEvent
attendance_shift_change班次变更AttendanceShiftChangeEvent
attendance_group_change考勤组变更AttendanceGroupChangeEvent
attendance_schedule_change员工排班变更AttendanceScheduleChangeEvent
attendance_overtime_duration员工加班AttendanceOvertimeDurationEvent
attend_bossCheck_change考勤结果变更AttendanceBossCheckChange
leave_rule_change假期规则变更AttendanceLeaveRuleChangeEvent
leave_quota_update手动修改假期余额AttendanceLeaveQuotaUpdateEvent
attendance_approve_status_change请假/加班/出差/外出状态变更AttendanceApproveStatusChangeEvent

群会话事件

事件类型说明事件类
chat_add_member群会话添加人员ChatAddMemberEvent
chat_remove_member群会话删除人员ChatRemoveMemberEvent
chat_quit用户主动退群ChatQuitEvent
chat_update_owner群会话更换群主ChatUpdateOwnerEvent
chat_update_title群会话更换群名称ChatUpdateTitleEvent
chat_disband群会话解散群ChatDisbandEvent

其他事件

事件类型说明事件类
check_url测试回调 URLCheckUrlEvent
suite_ticket套件票据的最新状态SuiteTicketEvent
todo_task_create待办任务新增TodoTaskCreateEvent
todo_task_update待办任务更新TodoTaskUpdateEvent
todo_task_delete待办任务删除TodoTaskDeleteEvent
calendar_event_change日程事件CalendarEventChangeEvent
hrm_user_record_change人事档案变动HrmUserRecordChangeEvent

HTTP 回调方式

使用 HTTP 回调接收事件:

import com.jeeapp.dingtalk.event.*;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
public class DingTalkEventHandler {

// 处理通用钉钉事件
@EventListener
public void handleDingTalkEvent(DingTalkEvent event) {
log.info("收到钉钉事件: eventType={}, eventId={}, corpId={}",
event.getEventType(), event.getEventId(), event.getCorpId());
}

// 处理审批实例开始事件
@EventListener
public void handleProcessInstanceStart(ProcessInstanceStartEvent event) {
log.info("审批实例开始: processInstanceId={}, processCode={}, staffId={}",
event.getProcessInstanceId(), event.getProcessCode(), event.getStaffId());

// 业务处理逻辑
String processInstanceId = event.getProcessInstanceId();
String processCode = event.getProcessCode();
// TODO: 保存审批实例信息
}

// 处理审批实例结束事件
@EventListener
public void handleProcessInstanceAgree(ProcessInstanceAgreeEvent event) {
log.info("审批实例通过: processInstanceId={}, result={}",
event.getProcessInstanceId(), event.getResult());

// 审批通过后的业务处理
if ("agree".equals(event.getResult())) {
// TODO: 审批通过的业务逻辑
}
}

// 处理审批实例拒绝事件
@EventListener
public void handleProcessInstanceRefuse(ProcessInstanceRefuseEvent event) {
log.info("审批实例拒绝: processInstanceId={}, result={}",
event.getProcessInstanceId(), event.getResult());

// 审批拒绝后的业务处理
if ("refuse".equals(event.getResult())) {
// TODO: 审批拒绝的业务逻辑
}
}

// 处理审批任务变更事件
@EventListener
public void handleProcessTask(ProcessTaskEvent event) {
log.info("审批任务变更: processInstanceId={}, type={}, staffId={}",
event.getProcessInstanceId(), event.getType(), event.getStaffId());

// 根据类型处理不同的任务事件
switch (event.getType()) {
case "start":
// 任务开始
break;
case "finish":
// 任务完成
break;
case "redirect":
// 任务转交
break;
default:
break;
}
}

// 处理员工加入事件
@EventListener
public void handleUserAdd(OrgUserAddEvent event) {
log.info("员工加入: userId={}", event.getUserId());

// 同步员工信息到本地
String userId = event.getUserId().get(0);
// TODO: 调用钉钉 API 获取完整用户信息并保存
}

// 处理员工离职事件
@EventListener
public void handleUserLeave(OrgUserLeaveEvent event) {
log.info("员工离职: userId={}", event.getUserId());

// 处理员工离职
String userId = event.getUserId().get(0);
// TODO: 更新本地员工状态为离职
}

// 处理部门创建事件
@EventListener
public void handleDeptCreate(OrgDeptCreateEvent event) {
log.info("部门创建: deptId={}", event.getDeptId());

// 同步部门信息
Long deptId = event.getDeptId().get(0);
// TODO: 调用钉钉 API 获取部门信息并保存
}

// 处理部门删除事件
@EventListener
public void handleDeptRemove(OrgDeptRemoveEvent event) {
log.info("部门删除: deptId={}", event.getDeptId());

// 删除部门
Long deptId = event.getDeptId().get(0);
// TODO: 删除本地部门数据
}

// 处理签到事件
@EventListener
public void handleCheckIn(CheckInEvent event) {
log.info("用户签到: userId={}, latitude={}, longitude={}",
event.getUserId(), event.getLatitude(), event.getLongitude());

// 处理签到业务
// TODO: 保存签到记录
}

// 处理打卡事件
@EventListener
public void handleAttendanceCheckRecord(AttendanceCheckRecordEvent event) {
log.info("员工打卡: userId={}, checkType={}, userCheckTime={}",
event.getUserId(), event.getCheckType(), event.getUserCheckTime());

// 处理打卡记录
// TODO: 保存打卡记录到本地
}
}

配置回调接口

创建接收事件回调的控制器:

import com.jeeapp.dingtalk.DingTalkTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.Map;

@RestController
@RequestMapping("/callback/dingtalk")
public class DingTalkCallbackController {

@Autowired
private DingTalkTemplate dingTalkTemplate;

/**
* 钉钉事件回调接口
* 需要在钉钉开放平台配置此回调 URL
*/
@PostMapping
public Map<String, String> handleCallback(
@RequestParam(required = false) String signature,
@RequestParam(required = false) String timestamp,
@RequestParam(required = false) String nonce,
@RequestBody(required = false) String encryptMsg) {

try {
// 发布事件到 Spring 事件系统
return dingTalkTemplate.publishEvent(signature, timestamp, nonce, encryptMsg);
} catch (Exception e) {
log.error("处理钉钉回调失败", e);
return Map.of("msg_signature", "error", "encrypt", "");
}
}
}

Stream 模式事件处理

使用 Stream 模式处理事件:

import com.dingtalk.open.app.api.callback.OpenDingTalkCallbackListener;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DingTalkStreamConfig {

@Bean
public OpenDingTalkCallbackListener<GenericOpenDingTalkEvent, Object> workflowEventListener() {
return (event, context) -> {
System.out.println("Stream 事件: " + event.getEventType());
return null;
};
}
}

自定义钉钉事件

Starter 已经为 钉钉事件订阅 中的常见事件类型预置了对应的事件类(位于 com.jeeapp.dingtalk.event 包,例如 OrgUserAddEventAbstractProcessEventAttendanceCheckRecordEvent 等)。当钉钉文档中新增事件类型,或者业务需要针对自定义控件的事件做强类型解析时,可通过继承 DingTalkEvent 并暴露为 Spring Bean 自动接入。

DingTalkEvent 基类

package com.jeeapp.dingtalk.event;

public abstract class DingTalkEvent implements Serializable {

@JSONField(name = "EventType", alternateNames = "eventType")
private String eventType;

@JSONField(name = "EventTime", alternateNames = "eventBornTime")
private Long eventTime;

@JSONField(name = "CorpId", alternateNames = {"corpId", "eventCorpId"})
private String corpId;

@JSONField(name = "eventId")
private String eventId;

@JSONField(name = "eventUnifiedAppId")
private String eventUnifiedAppId;

/** 未声明的字段会被收集到 data 中 */
@JSONField(unwrapped = true)
private Map<String, Object> data;

/** 子类必须实现:判断当前事件类是否能解析给定 eventType */
protected abstract boolean supportsEventType(String eventType);
}

DingTalkTemplate#publishEvent(String plainText) 会用 所有已注册的事件类 反序列化 JSON,然后通过 supportsEventType 过滤命中的事件,最后通过 Spring ApplicationEventPublisher 派发。

定义自定义事件类

参考 AttendanceCheckRecordEvent 的写法,对照 钉钉事件订阅 文档中的 回调数据示例,把字段映射成属性即可。例如订阅"日程变更"事件 calendar_event_change

import com.alibaba.fastjson.annotation.JSONField;
import com.jeeapp.dingtalk.event.DingTalkEvent;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class MyCalendarEventChangeEvent extends DingTalkEvent {

/** 日程所属用户 userId */
@JSONField(name = "userid")
private String userId;

/** 日程ID */
@JSONField(name = "eventId")
private String calendarEventId;

/** 变更类型:CREATE/UPDATE/DELETE */
@JSONField(name = "changeType")
private String changeType;

@Override
protected boolean supportsEventType(String eventType) {
return "calendar_event_change".equals(eventType);
}
}

要点:

  1. 必须继承 DingTalkEvent,并实现 supportsEventType。一个事件类可同时支持多个 eventType,只要在该方法中返回 true 即可。
  2. 字段使用 @JSONField(name = "...") 与钉钉回调 JSON 中的 原始 key 对齐(钉钉旧/新事件存在大小写差异,可用 alternateNames 同时兼容)。未显式声明的字段会被自动收集到 getData() Map 中,便于灰度过渡。
  3. 仅支持 无参构造;推荐使用 Lombok 的 @Getter/@Setter@Data

注册与扫描

DingTalkAutoConfiguration 默认会扫描 Spring Boot 主配置类所在包及其子包 下的所有 DingTalkEvent 子类(通过 AutoConfigurationPackages + DingTalkEvents.scan(packages) 实现),因此把自定义事件类放在主包下即可自动生效,无需任何额外配置

如果事件类位于其他包(例如独立 jar),可显式声明:

import com.jeeapp.dingtalk.DingTalkEvents;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyDingTalkEventConfig {

/**
* 覆盖默认扫描范围。也可以使用 DingTalkEvents.from(Set.of(MyCalendarEventChangeEvent.class))
* 精确注册某些事件。
*/
@Bean
public DingTalkEvents dingTalkEvents() {
return DingTalkEvents.scan(
"com.example.dingtalk.event",
"com.example.shared.dingtalk"
);
}
}

也可以在手动构建 DingTalkTemplate 时通过 DingTalkTemplateBuilder#additionalEventTypes 追加:

DingTalkTemplate template = new DingTalkTemplateBuilder()
.config(config)
.additionalEventTypes(MyCalendarEventChangeEvent.class)
.build();

消费自定义事件

无论是 HTTP 回调还是 Stream 模式,自定义事件都会通过 Spring 的 @EventListener 机制派发。直接监听具体类型即可:

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MyCalendarEventHandler {

@EventListener
public void onCalendarChange(MyCalendarEventChangeEvent event) {
log.info("日程变更: corpId={}, userId={}, calendarEventId={}, changeType={}",
event.getCorpId(), event.getUserId(),
event.getCalendarEventId(), event.getChangeType());
// 业务处理...
}

/**
* 也可以监听父类 DingTalkEvent,统一收口未在代码中显式建模的事件,
* 这种情况下只能通过 event.getData() 拿到原始字段。
*/
@EventListener
public void fallback(DingTalkEvent event) {
log.debug("收到钉钉事件: type={}, payload={}", event.getEventType(), event.getData());
}
}
备注

HTTP 回调入口为 DingTalkTemplate#publishEvent(signature, timestamp, nonce, encryptMsg)(已在 <<dingtalk-event-callback-controller>> 中给出 Controller 样例);Stream 入口由 Starter 自动注册到 OpenDingTalkStreamClient 上。两条路径最终都会调用 publishEvent(String plainText)DingTalkEvents.searchForStreamApplicationEventPublisher.publishEvent

API 参考

DingTalkTemplate 主要方法

方法名参数返回值
getAccessToken()String - 访问令牌
getUserAccessToken(userId, code)userId, codeString - 用户访问令牌
getJsapiTicket(url)urlMap<String, Object> - JSAPI 配置
workflowOperations()WorkflowOperations - 工作流操作
messageOperations()MessageOperations - 消息操作
cardOperations()CardOperations - 卡片操作
yidaOperations()YidaOperations - 宜搭操作
createClient(clientClass)clientClassClient - 创建客户端
execute(request)requestResponse - 执行请求

WorkflowOperations 主要方法

方法名参数返回值
processInstance()ProcessInstanceBuilder - 流程实例构建器
getProcessName(processCode)processCodeString - 流程名称
clearProcessCache(formCode, name)formCode, namevoid - 清除流程缓存

MessageOperations 主要方法

方法名参数返回值
corpConversationMessage()CorpConversationMessageBuilder - 企业消息构建器
customRobotMessage()CustomRobotMessageBuilder - 自定义机器人消息构建器
robotBatchMessage()RobotBatchMessageBuilder - 机器人批量消息构建器

CardOperations 主要方法

方法名参数返回值
createCard()CreateCardBuilder - 创建卡片构建器
updateCard()UpdateCardBuilder - 更新卡片构建器

YidaOperations 主要方法

所有方法均返回链式 FormRequestBuilder 子类,调用 customize(...) 追加 SDK 原生字段后通过 execute() 触发请求。target 为带 @YidaForm 注解的 Bean 实例(写入类);targetClass 仅用于解析 @YidaForm 元信息(读取/搜索/删除类)。

表单数据(V2)

方法名参数返回值
saveFormDataV2(target)T targetSaveFormDataV2Builder - 新建表单数据
updateFormDataV2(target)T targetUpdateFormDataV2Builder - 更新表单数据
createOrUpdateFormDataV2(target)T targetCreateOrUpdateFormDataV2Builder - 创建或更新
getFormDataByIDV2(targetClass)Class<T> targetClassGetFormDataByIDV2Builder - 按 ID 查询
searchFormDatasV2(target)T targetSearchFormDatasV2Builder - 多条件搜索
searchFormDataSecondGenerationV2(target)T targetSearchFormDataSecondGenerationV2Builder - 二代搜索

流程实例(V2)

方法名参数返回值
startInstanceV2(target)T targetStartInstanceV2Builder - 发起流程实例
getInstanceByIdV2(targetClass)Class<T> targetClassGetInstanceByIdV2Builder - 按实例 ID 查询
getInstanceIdListV2(target)T targetGetInstanceIdListV2Builder - 获取实例 ID 列表
getInstancesV2(target)T targetGetInstancesV2Builder - 分页获取实例详情

表单数据(V1,批量与扩展)

方法名参数返回值
batchGetFormDataByIdList(targetClass)Class<T> targetClassBatchGetFormDataByIdListBuilder - 按 ID 列表批量查询
batchSaveFormData(targets)List<T> targetsBatchSaveFormDataBuilder - 批量新增
batchUpdateFormDataByInstanceId(target)T targetBatchUpdateFormDataByInstanceIdBuilder - 按 ID 批量更新
batchUpdateFormDataByInstanceMap(targets)Map<String, T> targetsBatchUpdateFormDataByInstanceMapBuilder - 按 Map 批量更新
deleteFormData(targetClass)Class<T> targetClassDeleteFormDataBuilder - 删除表单数据
batchRemovalByFormInstanceIdList(targetClass)Class<T> targetClassBatchRemovalByFormInstanceIdListBuilder - 批量逻辑删除
searchFormDataRemovalTableData(target)T targetSearchFormDataRemovalTableDataBuilder - 搜索回收站数据
searchFormDataSecondGenerationNoTableField(target)T targetSearchFormDataSecondGenerationNoTableFieldBuilder - 二代搜索(不含子表单)
getFormListInApp(targetClass)Class<T> targetClassGetFormListInAppBuilder - 应用下表单列表

流程实例(V1)

方法名参数返回值
deleteInstance(targetClass)Class<T> targetClassDeleteInstanceBuilder - 删除流程实例
terminateInstance(targetClass)Class<T> targetClassTerminateInstanceBuilder - 终止流程实例
executeTask(target)T targetExecuteTaskBuilder - 执行流程任务
getInstancesByIdList(targetClass)Class<T> targetClassGetInstancesByIdListBuilder - 按实例 ID 列表查询

完整示例

完整应用示例

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import com.jeeapp.dingtalk.DingTalkTemplate;
import com.jeeapp.dingtalk.WorkflowOperations;
import com.jeeapp.dingtalk.MessageOperations;
import com.jeeapp.dingtalk.request.workflow.ProcessInstanceBuilder;
import com.jeeapp.dingtalk.request.message.CorpConversationMessageBuilder;

@SpringBootApplication
public class DingTalkDemoApplication {
public static void main(String[] args) {
SpringApplication.run(DingTalkDemoApplication.class, args);
}
}

@RestController
@RequestMapping("/api/dingtalk")
public class DingTalkApiController {

private final DingTalkTemplate dingTalkTemplate;

public DingTalkApiController(DingTalkTemplate dingTalkTemplate) {
this.dingTalkTemplate = dingTalkTemplate;
}

@GetMapping("/user/{userId}")
public Object getUser(@PathVariable String userId) {
try {
com.aliyun.dingtalkcontact_1_0.Client client = dingTalkTemplate.createClient(com.aliyun.dingtalkcontact_1_0.Client.class);
return client.getUser(userId).getBody();
} catch (Exception e) {
return Map.of("error", e.getMessage());
}
}

@PostMapping("/workflow/create")
public Object createWorkflow(@RequestBody Map<String, Object> request) {
try {
WorkflowOperations workflowOps = dingTalkTemplate.workflowOperations();

// 使用 ProcessInstanceBuilder 直接构建
ProcessInstanceBuilder builder = new ProcessInstanceBuilder(dingTalkTemplate, null)
.processCode((String) request.get("processCode"))
.originatorUserId((String) request.get("originatorUserId"))
.formComponentValue("申请人", (String) request.get("applicant"))
.formComponentValue("申请事由", (String) request.get("reason"));

String instanceId = builder.create();
return Map.of("instanceId", instanceId);
} catch (Exception e) {
return Map.of("error", e.getMessage());
}
}

@PostMapping("/message/send")
public Object sendMessage(@RequestBody Map<String, Object> request) {
try {
MessageOperations messageOps = dingTalkTemplate.messageOperations();

CorpConversationMessageBuilder builder = messageOps.createCorpConversationMessage()
.agentId(dingTalkTemplate.getAppConfig().getAgentId())
.userIdList((String) request.get("userId"))
.msgType("text")
.text((String) request.get("content"));

builder.send();
return Map.of("success", true);
} catch (Exception e) {
return Map.of("error", e.getMessage());
}
}
}

相关资源