钉钉补卡事件处理方案

文章提供了两种方案, 及相关消息用例供大家参考

  1. 通过员工打卡事件实现
  2. 通过审批实例开始, 结束 结合审批单详情接口实现

注: 下方操作的应用类型为企业内部应用开发

一, 员工打卡事件实现

首先需要订阅员工打卡事件, 如图

钉钉 Stream 模式SpringBoot接入配置与事件监听_钉钉stream依赖添加-CSDN博客

平台对员工打卡事件的描述文档如下:
员工打卡事件文档

官方推送消息示例(data部分):

json 复制代码
{
    "eventId": "3b15cb8159204bfe9e60f4f276fdc29c",
    "dataList": [
        {
            "checkTime": 1756083600000,
            "corpId": "dinged281b9ee2134221a39a90f97fcb1e09",
            "locationResult": "Normal",
            "groupId": "8901A759EB795D24C8A1FBEA24FE5CC7",
            "bizId": "23244E2D4ADBBC2BBFA475EBA6303735",
            "locationMethod": "OTHER",
            "checkByUser": true,
            "userId": "02253726682920171716"
        }
    ]
}

通过官方推送的消息可以知道打卡(补卡)时间checkTime (也就可以知道是上班还是下班), 可知道用户userId, 知道修改的状态locationResult, 通过这三个值即可对补卡做处理

注:

这里有一个问题, 这里会把所有的消息都推送过来, 打卡, 补卡等, 所以比较消耗Webhook 和 Stream 用量一个月只有5000次, 可以根据实际情况算一下, 如果够用, 这种方案肯定最简便

二.通过审批实例开始, 结束事件 和 审批单详情接口实现

首先需要订阅审批实例开始,结束, 如图

然后通过, 上一篇文章中介绍的接入进行配置及开发,后面就是处理消息的部分, 根据自己的业务逻辑就行处理即可

钉钉 Stream 模式SpringBoot接入配置与事件监听_钉钉stream依赖添加-CSDN博客

官方说明: 审批实例开始、结束、终止、删除钉钉文档地址

官方推送消息示例(data部分):

json 复制代码
{
    "processInstanceId": "qWU68--WSIasZMhIzLHxuA04431756109057",
    "eventId": "eff61099a455461b81b3d9229e980b33",
    "finishTime": 1756110624000,
    "resource": "/v1.0/event/bpms_instance_change/bizCategoryId/attendance.supply/processCode/PROC-8A8DFDCF-12B4-4CC7-9983-A91DD2F1D2D0/type/finish",
    "businessId": "202508251604000176498",
    "title": "大帅哥提交的补卡申请",
    "type": "finish",
    "url": "https://aflow.dingtalk.com/dingtalk/mobile/homepage.htm?corpid=dinged281b9ee2134221a39a90f97fcb1e09&dd_share=false&showmenu=false&dd_progress=false&back=native&procInstId=qWU68--WSIasZMhIzLHxuA04431756109057&taskId=&swfrom=isv&dinghash=approval&dtaction=os&dd_from=#approval",
    "result": "agree",
    "createTime": 1756109058000,
    "processCode": "PROC-8A8DFDCF-12B4-4CC7-9983-A91DD2F1D2D0",
    "bizCategoryId": "attendance.supply",
    "staffId": "02253726682920171716"
}

当看到这个json的时候可以看到没有明确的打卡时间, 经查阅可以通过审批单ID.再调用审批单详情接口来获取 补卡时间, 如下:

官方文档地址:
获取单个审批实例详情

java代码如下:

java 复制代码
    /**
     * 获取钉钉工作流客户端
     *
     * @return 钉钉工作流客户端
     */
    public static com.aliyun.dingtalkworkflow_1_0.Client createClient2() {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        try {
            return new com.aliyun.dingtalkworkflow_1_0.Client(config);
        } catch (Exception e) {
            log.error("初始化钉钉工作流Client 失败, 原因: {}", e.getMessage());
        }
        return null;
    }    


	/**
     * 获取补卡时间
     *
     * @return 补卡时间 yyyy-MM-dd HH:mm
     */
    public String getReplacementCardDate(String processInstanceId) {
        com.aliyun.dingtalkworkflow_1_0.Client client = createClient2();
        if (Objects.isNull(client)) {
            return null;
        }
        com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceHeaders getProcessInstanceHeaders = new com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceHeaders();
        getProcessInstanceHeaders.xAcsDingtalkAccessToken = getAccessToken();
        com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceRequest getProcessInstanceRequest = new com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceRequest()
                .setProcessInstanceId(processInstanceId);
        try {
            GetProcessInstanceResponse processInstanceWithOptions = client.getProcessInstanceWithOptions(getProcessInstanceRequest, getProcessInstanceHeaders, new RuntimeOptions());
            GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResult result = processInstanceWithOptions.getBody().getResult();
            log.info("事件标题:{}", result.getTitle());

            GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues dateFieldComponentValue = new GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues();
            for (GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues formComponentValue : result.getFormComponentValues()) {
                if ("DDDateField".equals(formComponentValue.getComponentType())) {
                    dateFieldComponentValue = formComponentValue;
                    break;
                }
            }

            // 补卡时间
            // 时间格式: yyyy-MM-dd HH:mm  示例: 2025-08-25 12:00
            return dateFieldComponentValue.getValue();
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("code:{},  message:{}", err.code, err.message);
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("code:{}, message:{}", err.code, err.message);
            }
        }
        return null;
    }

现在就可以知道, 补卡时间示例: 2025-08-25 09:00, 用户ID, 然后做修改补卡操作

这里可能会问一个问题, 为什么一定是成功, 按照逻辑来说, 只要能提交补卡审批, 就说明还有补交次数, 只要系统能收到消息, 就说明肯定的是成功了, 这是其一, 其二就是可以看到通过"result": "agree",这个参数判断, 表示通过, 通过则状态就是Normal.


最后还有一点需要注意和修改的, 可以指定订阅的地址, 因为钉钉中的审批单有很多很多, 如果没有配置, 会接收到很多暂时没有用的数据(审批单), 同时也会消耗Webhook 和 Stream 用量, 所以可以通过通配符, 或者指定所需审批中的确定事件

补卡事件订阅地址:

json 复制代码
/v1.0/event/bpms_instance_change/bizCategoryId/attendance.supply/processCode/{processCode}/type/finish

// 这里只订阅通过的事件

事件订阅的匹配规则采用Glob语法进行模式匹配可以在图片中的5点击查阅, 或点击下方链接

事件订阅的匹配规则

具体的可以在官方文档中查看


附:

自定义工作类:

java 复制代码
package com.gkl.attendance.utils.aliyun;

import com.aliyun.dingtalkcontact_1_0.models.SearchUserResponse;
import com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceResponse;
import com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceResponseBody;
import com.aliyun.tea.TeaException;
import com.aliyun.teautil.models.RuntimeOptions;
import com.dingtalk.api.DefaultDingTalkClient;
import com.dingtalk.api.DingTalkClient;
import com.dingtalk.api.request.OapiAttendanceListRequest;
import com.dingtalk.api.request.OapiGettokenRequest;
import com.dingtalk.api.response.OapiAttendanceListResponse;
import com.dingtalk.api.response.OapiGettokenResponse;
import com.taobao.api.ApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;
import java.util.Objects;

/**
 * @author : Cookie
 * date : 2025/8/22
 * desc: 钉钉API工具类
 */
@Slf4j
@Component
public class DingDingAPIUtils {

    @Value("${dingtalk.appKey}")
    private String appKey;

    @Value("${dingtalk.appSecret}")
    private String appSecret;

    /**
     * 通过appKey和appSecret获取access_token
     *
     * @return access_token
     */
    public String getAccessToken() {
        try {
            DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/gettoken");
            OapiGettokenRequest req = new OapiGettokenRequest();
            req.setAppkey(appKey);
            req.setAppsecret(appSecret);
            req.setHttpMethod("GET");
            OapiGettokenResponse rsp = client.execute(req);
            return rsp.getAccessToken();
        } catch (ApiException e) {
            log.error(e.getErrMsg());
        }
        return null;
    }

    /**
     * 获取指定时间打卡结果
     *
     * @param dingDingIdList 钉钉用户ID 列表
     * @param checkDateFrom  开始日期
     * @param checkDateTo    结束日期
     */
    public OapiAttendanceListResponse getCheckRecord(List<String> dingDingIdList, String checkDateFrom, String checkDateTo) {
        DingTalkClient client = new DefaultDingTalkClient("https://oapi.dingtalk.com/attendance/list");
        OapiAttendanceListRequest req = new OapiAttendanceListRequest();
        req.setWorkDateFrom(checkDateFrom);
        req.setWorkDateTo(checkDateTo);
        req.setUserIdList(dingDingIdList);
        req.setOffset(0L);
        req.setLimit(50L);
        try {
            OapiAttendanceListResponse rsp = client.execute(req, getAccessToken());
            log.info("获取考勤记录响应结果:{}", rsp.getBody());
            return rsp;
        } catch (ApiException e) {
            log.error("获取考勤记录失败, 原因: {}", e.getErrMsg());
            throw new RuntimeException(e);
        }
    }

    /**
     * 获取钉钉连接客户端
     *
     * @return 钉钉连接客户端
     */
    public com.aliyun.dingtalkcontact_1_0.Client createClient() {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";

        try {
            return new com.aliyun.dingtalkcontact_1_0.Client(config);
        } catch (Exception e) {
            log.error("初始化钉钉Client 失败, 原因: {}", e.getMessage());
        }
        return null;
    }

    /**
     * 根据用户昵称模糊/精确查询钉钉用户ID列表
     *
     * @param nickName 用户昵称(模糊/精确匹配)
     * @return 钉钉用户ID列表,如果查询失败返回 null
     */
    public List<String> getDingIdListByNickName(String nickName) {
        com.aliyun.dingtalkcontact_1_0.Client client = createClient();
        if (Objects.isNull(client)) {
            return Collections.emptyList();
        }

        com.aliyun.dingtalkcontact_1_0.models.SearchUserHeaders searchUserHeaders =
                new com.aliyun.dingtalkcontact_1_0.models.SearchUserHeaders();
        // 设置 AccessToken
        searchUserHeaders.xAcsDingtalkAccessToken = getAccessToken();

        // 构造查询请求
        com.aliyun.dingtalkcontact_1_0.models.SearchUserRequest searchUserRequest =
                new com.aliyun.dingtalkcontact_1_0.models.SearchUserRequest()
                        .setQueryWord(nickName)
                        .setOffset(1)
                        .setSize(10)
                        .setFullMatchField(1);

        try {
            SearchUserResponse searchUserResponse =
                    client.searchUserWithOptions(searchUserRequest, searchUserHeaders, new RuntimeOptions());

            log.info("钉钉用户昵称查询结果:{}", searchUserResponse.getBody().getList());
            return searchUserResponse.getBody().getList();

        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("通过用户名获取用户钉钉ID 失败, code:{}, message:{}", err.code, err.message);
            }
        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("通过用户名获取用户钉钉ID失败, code:{}, message:{}", err.code, err.message);
            }
        }
        return Collections.emptyList();
    }

    /**
     * 获取钉钉工作流客户端
     *
     * @return 钉钉工作流客户端
     */
    public static com.aliyun.dingtalkworkflow_1_0.Client createClient2() {
        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config();
        config.protocol = "https";
        config.regionId = "central";
        try {
            return new com.aliyun.dingtalkworkflow_1_0.Client(config);
        } catch (Exception e) {
            log.error("初始化钉钉工作流Client 失败, 原因: {}", e.getMessage());
        }
        return null;
    }

    /**
     * 获取补卡时间
     *
     * @return 补卡时间
     */
    public String getReplacementCardDate(String processInstanceId) {
        com.aliyun.dingtalkworkflow_1_0.Client client = createClient2();
        if (Objects.isNull(client)) {
            return null;
        }
        com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceHeaders getProcessInstanceHeaders = new com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceHeaders();
        getProcessInstanceHeaders.xAcsDingtalkAccessToken = getAccessToken();
        com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceRequest getProcessInstanceRequest = new com.aliyun.dingtalkworkflow_1_0.models.GetProcessInstanceRequest()
                .setProcessInstanceId(processInstanceId);
        try {
            GetProcessInstanceResponse processInstanceWithOptions = client.getProcessInstanceWithOptions(getProcessInstanceRequest, getProcessInstanceHeaders, new RuntimeOptions());
            GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResult result = processInstanceWithOptions.getBody().getResult();
            log.info("事件标题:{}", result.getTitle());

            GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues dateFieldComponentValue = new GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues();
            for (GetProcessInstanceResponseBody.GetProcessInstanceResponseBodyResultFormComponentValues formComponentValue : result.getFormComponentValues()) {
                if ("DDDateField".equals(formComponentValue.getComponentType())) {
                    dateFieldComponentValue = formComponentValue;
                    break;
                }
            }

            // 补卡时间
            // 时间格式: yyyy-MM-dd HH:mm  示例: 2025-08-25 12:00
            return dateFieldComponentValue.getValue();
        } catch (TeaException err) {
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("code:{},  message:{}", err.code, err.message);
            }

        } catch (Exception _err) {
            TeaException err = new TeaException(_err.getMessage(), _err);
            if (!com.aliyun.teautil.Common.empty(err.code) && !com.aliyun.teautil.Common.empty(err.message)) {
                log.error("code:{}, message:{}", err.code, err.message);
            }
        }
        return null;
    }
}
相关推荐
小毅&Nora4 分钟前
【Java线程安全实战】⑨ CompletableFuture的高级用法:从基础到高阶,结合虚拟线程
java·线程安全·虚拟线程
冰冰菜的扣jio4 分钟前
Redis缓存中三大问题——穿透、击穿、雪崩
java·redis·缓存
PyHaVolask7 分钟前
SQL注入漏洞原理
数据库·sql
小璐猪头17 分钟前
专为 Spring Boot 设计的 Elasticsearch 日志收集 Starter
java
ptc学习者18 分钟前
黑格尔时代后崩解的辩证法
数据库
代码游侠22 分钟前
应用——智能配电箱监控系统
linux·服务器·数据库·笔记·算法·sqlite
ps酷教程37 分钟前
HttpPostRequestDecoder源码浅析
java·http·netty
闲人编程37 分钟前
消息通知系统实现:构建高可用、可扩展的企业级通知服务
java·服务器·网络·python·消息队列·异步处理·分发器
!chen41 分钟前
EF Core自定义映射PostgreSQL原生函数
数据库·postgresql
霖霖总总1 小时前
[小技巧14]MySQL 8.0 系统变量设置全解析:SET GLOBAL、SET PERSIST 与 SET PERSIST_ONLY 的区别与应用
数据库·mysql