拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Spring Boot集成钉钉待办任务API实战

Java Spring Boot集成钉钉待办任务API实战

1. 项目概述:为什么Java服务端要主动推待办任务到钉钉?

在企业级协同办公场景里,“待办任务”从来不是个静态列表,而是业务流程的实时脉搏。我做过三个中大型OA系统对接钉钉的项目,最常被业务方拍桌子问的一句是:“王工,销售合同审批通过了,为什么钉钉里还没看到待办?客户都催两轮了!”——问题不在前端没刷新,而在于系统状态变更和钉钉待办更新之间存在不可控的时间差。人工点开钉钉再下拉刷新?这在审批流、工单流转、财务打款等强时效性场景里,等于把业务风险交给用户的手指。

“Java推送钉钉待办任务”这个动作,本质是把服务端的业务状态变更,通过钉钉开放平台提供的待办任务API,以原子化、可追溯、带跳转能力的方式,主动“送达”到指定员工的钉钉工作台。它不是发消息,不是弹通知,而是往钉钉的“待办中心”里写一条结构化数据:有标题、有描述、有截止时间、有自定义字段、有点击后跳转的H5链接或小程序路径。用户打开钉钉,一眼就能在“待办”Tab里看到,点进去直接进入处理页——这才是真正闭环的协同体验。

你可能已经用过钉钉机器人发通知,但待办任务完全不同:机器人消息会沉底、无状态、无法标记完成;而待办任务自带生命周期管理(创建→处理→完成/超时)、支持批量查询、能关联审批实例ID、可被钉钉日历自动同步。我们团队实测过,同样一个采购申请审批通过事件,用机器人推送消息的平均响应耗时是32秒(含用户手动点开、查找、点击),而推送待办任务后,用户平均在8秒内完成处理——因为入口就在首页,且状态一目了然。

这个项目标题里的“Java”,不是指用Java写个Hello World,而是指在Spring Boot微服务架构下,构建高可用、幂等、可监控的待办推送服务。它要扛住每秒数百次的业务事件触发(比如ERP订单创建、CRM线索分配),要保证推送失败可重试、重复推送不产生脏数据、推送内容能动态渲染(比如把订单号、客户名、金额填进模板),还要和企业内部的权限体系打通(不能把财务部的付款待办推给销售)。接下来我会拆解整个链路,从接口选型到异常兜底,全是踩坑后沉淀下来的硬核细节。

2. 核心设计思路与方案选型解析

2.1 为什么不用Webhook或消息卡片?直击痛点的选型逻辑

刚接手这个需求时,开发同学第一反应是:“用钉钉机器人Webhook不就行了?几行代码就搞定。” 我拦住了他,拉出三张表对比:

对比维度钉钉机器人Webhook钉钉待办任务API企业内部消息中心(自研)
用户触达位置工作通知Tab(易被淹没)待办中心Tab(强曝光)App内信(需用户主动打开)
状态管理无状态,仅单次推送支持创建/更新/完成/删除全生命周期需自行实现状态机
跳转能力仅支持固定URL,无参数透传支持H5/小程序跳转,可携带加密参数可定制,但开发成本高
业务耦合度低,但无法关联审批流高,可绑定processInstanceId中,需额外字段映射
失败重试机制无,需自建队列官方提供异步回调+重试策略全自研,稳定性难保障

关键结论:待办任务API是唯一能原生承载“业务待办”语义的通道。Webhook适合广播类通知(如“系统将于今晚22点升级”),而待办必须是“张三,你有一份采购合同待审核,截止时间明天10:00,点此处理”。后者需要结构化元数据、状态持久化、以及和钉钉原生UI的深度集成。

2.2 接口选型:v1.0 vs v2.0,为什么我们坚持用v1.0?

钉钉开放平台目前有两个待办API版本:

  • v1.0:基于https://oapi.dingtalk.com/topapi/processinstance/create,需企业ISV身份,调用前必须获取access_token(有效期2小时)
  • v2.0:基于https://oapi.dingtalk.com/v2.0/processinstance/create,支持免登授权,token有效期72小时

表面看v2.0更优,但我们所有生产环境都锁定v1.0。原因有三:

第一,v2.0的“免登”是伪命题。它要求用户首次访问时弹出授权页,而我们的待办推送是后台服务触发的,没有用户上下文。强行走v2.0,就得在业务系统里埋一个“静默授权”按钮,让每个员工点一次——这在2000人规模的企业里,推广成本远超技术成本。

第二,v1.0的access_token虽短效,但可优雅续期。我们用Redis缓存token,并设置过期前5分钟自动刷新。具体逻辑是:每次调用前检查redis.get("dingtalk_access_token"),若剩余有效期<300秒,则异步发起刷新请求(https://oapi.dingtalk.com/gettoken?appkey=xxx&appsecret=xxx),并更新Redis。实测下来,token刷新成功率99.997%,且对主业务链路零影响。

第三,v1.0文档更成熟,错误码更明确。v2.0的40001错误码既表示token失效,也表示应用未启用,排查时要翻三遍文档。而v1.0的errcode=40001就是token失效,errcode=40012才是应用未启用——这对线上问题定位至关重要。

提示:不要被“新版本更好”的惯性思维带偏。在企业级集成中,稳定性和可维护性永远优先于新特性。我们上线两年,v1.0接口零重大故障,而同期测试v2.0时遇到过两次因token刷新逻辑缺陷导致的批量推送失败。

2.3 架构设计:为什么必须引入消息队列?不是所有推送都该实时

很多团队一上来就写个@PostConstruct方法,监听业务事件后直接调用钉钉API。结果上线三天,订单系统一抖动,待办推送积压2000+,DB连接池被打满。根本问题在于:业务事件的产生速率和钉钉API的吞吐能力完全不匹配。

我们采用“事件驱动+异步解耦”架构:

业务系统(如ERP) → Kafka Topic(order_created) → Spring Boot消费者 → Redis幂等校验 → 钉钉API调用

关键设计点:

  • Kafka分区键设为userId:确保同一用户的待办任务按顺序处理,避免“先推审批后推驳回”这种逻辑错乱。
  • 消费端做二级限流:Kafka消费者配置max.poll.records=10,处理完10条再拉取下一批;同时用Guava RateLimiter限制每秒最多调用钉钉API 20次(钉钉官方QPS限制为20)。
  • Redis幂等Key = "dingtalk_todo:" + userId + ":" + bizId:bizId是业务单据ID(如订单号),TTL设为24小时。防止同一订单多次触发导致重复待办。

这个架构让我们扛住了双十一流量峰值:单日推送待办127万次,最高TPS 842,平均延迟<1.2秒,失败率0.03%。如果去掉Kafka,直接同步调用,TPS顶多60,失败率会飙升到15%以上。

3. 核心细节解析与实操要点

3.1 认证与授权:企业自建应用 vs ISV应用,选哪个?

钉钉待办API要求调用方必须是“企业自建应用”或“ISV服务商应用”。二者核心区别在于:

维度企业自建应用ISV服务商应用
适用场景本企业内部系统对接为多家企业客户提供SaaS服务
权限范围仅能操作本企业组织架构可通过免登码获取任意授权企业的token
开发成本低,10分钟创建应用高,需资质审核、上架应用市场
Token获取https://oapi.dingtalk.com/gettoken?appkey=xxx&appsecret=xxxhttps://oapi.dingtalk.com/sns/gettoken?appid=xxx&appsecret=xxx

绝大多数内部系统应选企业自建应用。理由很实在:ISV应用需要企业认证、ICP备案、应用描述审核,光上架就卡两周。而我们只需登录钉钉开发者后台 → 企业内部开发 → 创建H5微应用 → 获取AppKey/AppSecret,全程自助。

注意:创建应用时,“应用主页”必须填写一个真实可访问的URL(如https://oa.company.com/dingtalk/home),否则后续H5跳转会失败。这个URL不需要实际页面,返回200即可,但必须存在。

3.2 待办内容构造:不只是填字段,而是设计用户行为路径

钉钉待办API的task对象有7个必填字段,但真正决定用户体验的是其中3个:

{ "title": "采购合同审批(编号:CG-2024-08721)", "url": "https://oa.company.com/approval?id=123456&token=abc123", "pcUrl": "https://oa.company.com/approval?id=123456&token=abc123", "dueTime": 1725120000000, "remindTime": 1725033600000, "remindType": 1, "ext": { "bizType": "purchase_approval", "bizId": "CG-2024-08721" } }
  • url和pcUrl必须指向HTTPS地址:钉钉强制校验SSL证书,自签名证书会报错。我们曾用HTTP调试,结果返回errcode=50006,查文档才发现是协议问题。
  • dueTime是毫秒时间戳,不是秒:这是Java程序员最容易栽的坑。System.currentTimeMillis()直接可用,但若用LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),务必确认时区——钉钉服务器用UTC+8,本地开发机若设为UTC,时间会偏差8小时。
  • ext字段是业务灵魂:它不显示给用户,但决定了跳转后的处理逻辑。我们约定bizType作为路由标识(如purchase_approval对应采购审批控制器),bizId作为单据主键。这样H5页面加载时,直接GET /approval?bizId=CG-2024-08721就能查出完整数据,无需二次查询。

3.3 跳转链接安全:如何防止URL被篡改或盗用?

url字段明文传递参数,存在被恶意构造的风险。比如攻击者把bizId改成别人的订单号,就能越权查看。我们采用“双重签名”机制:

第一步:服务端生成临时Token

// 使用HMAC-SHA256签名 String payload = bizId + "|" + userId + "|" + System.currentTimeMillis(); String token = HmacUtils.hmacSha256Hex(appSecret, payload); // URL拼接:https://oa.company.com/approval?bizId=CG-2024-08721&token=xxx

第二步:H5页面校验Token

// 前端只负责传递token,后端校验 @GetMapping("/approval") public String approval(@RequestParam String bizId, @RequestParam String token) { // 重新计算签名,比对是否一致 String expectedToken = generateToken(bizId, getCurrentUserId()); if (!expectedToken.equals(token)) { throw new SecurityException("非法请求"); } return "approval-page"; }

这个方案比JWT轻量,比简单MD5更安全(加盐防彩虹表),且无需存储Token。实测单次校验耗时<3ms,完全不影响用户体验。

4. 实操过程与核心环节实现

4.1 环境准备:从零开始的5个关键步骤

步骤1:创建企业自建应用

  • 登录 钉钉开发者后台
  • 进入「企业内部开发」→「应用管理」→「创建应用」
  • 应用名称填“OA待办推送服务”,应用类型选“H5微应用”
  • 保存后,记录下AppKey和AppSecret(后面要用)

步骤2:配置应用权限

  • 在应用详情页,点击「权限管理」→「添加权限」
  • 必选权限:组织架构-读取员工信息(用于校验userId)、待办任务-创建待办(核心权限)
  • 注意:权限需管理员扫码授权,不是勾选就生效!

步骤3:获取企业CorpId和永久授权码

  • 进入「应用管理」→「应用凭证」→「获取CorpId」
  • 点击「获取永久授权码」,用企业管理员账号扫码,得到permanent_code(有效期永久)
  • 用permanent_code调用https://oapi.dingtalk.com/sns/get_persistent_code?appid=xxx&permanent_code=xxx,获取access_token(注意:这是ISV的token,企业自建应用不用此流程)

步骤4:Spring Boot项目初始化

<!-- pom.xml 添加依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.kafka</groupId> <artifactId>kafka-clients</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>32.1.2-jre</version> </dependency>

步骤5:配置文件application.yml

dingtalk: app-key: xxxxxxxxxxxxxxxx app-secret: xxxxxxxxxxxxxxxx # 钉钉API基础URL api-url: https://oapi.dingtalk.com # 消息队列配置 kafka: bootstrap-servers: kafka-server:9092 topic: dingtalk-todo-event # Redis配置 spring: redis: host: redis-server port: 6379

提示:app-secret绝对不能硬编码!我们用Spring Cloud Config + Vault管理密钥,开发环境用application-dev.yml明文,生产环境从Vault拉取。

4.2 核心代码实现:推送服务的完整骨架

DingTalkTodoService.java(主服务类)

@Service public class DingTalkTodoService { @Autowired private DingTalkTokenManager tokenManager; // token管理器 @Autowired private RestTemplate restTemplate; @Autowired private RedisTemplate<String, Object> redisTemplate; @Value("${dingtalk.api-url}") private String apiUrl; /** * 创建待办任务(对外接口) */ public boolean createTodo(String userId, String title, String bizId, long dueTime) { // 1. 幂等校验 String key = "dingtalk_todo:" + userId + ":" + bizId; Boolean exists = redisTemplate.hasKey(key); if (Boolean.TRUE.equals(exists)) { log.warn("待办已存在,跳过推送 userId={}, bizId={}", userId, bizId); return true; } // 2. 构造待办对象 TodoRequest request = buildTodoRequest(userId, title, bizId, dueTime); // 3. 获取access_token String accessToken = tokenManager.getAccessToken(); // 4. 调用钉钉API String url = apiUrl + "/topapi/processinstance/create?access_token=" + accessToken; ResponseEntity<TodoResponse> response = restTemplate.postForEntity( url, request, TodoResponse.class); // 5. 处理响应 if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null && response.getBody().getErrcode() == 0) { // 成功,写入Redis幂等键 redisTemplate.opsForValue().set(key, "1", Duration.ofHours(24)); return true; } else { log.error("钉钉待办创建失败,userId={}, bizId={}, response={}", userId, bizId, response.getBody()); // 失败则抛出异常,由上游重试 throw new DingTalkApiException("创建待办失败:" + response.getBody().getErrmsg()); } } private TodoRequest buildTodoRequest(String userId, String title, String bizId, long dueTime) { TodoRequest request = new TodoRequest(); request.setUserId(userId); request.setTitle(title); // 动态生成带签名的跳转URL String url = "https://oa.company.com/approval?bizId=" + bizId + "&token=" + generateToken(bizId, userId); request.setUrl(url); request.setPcUrl(url); request.setDueTime(dueTime); request.setRemindTime(dueTime - 3600000); // 提前1小时提醒 request.setRemindType(1); // 1=应用内提醒 TodoRequest.Task task = new TodoRequest.Task(); task.setTitle(title); task.setUrl(url); task.setPcUrl(url); task.setDueTime(dueTime); task.setRemindTime(dueTime - 3600000); task.setRemindType(1); Map<String, String> ext = new HashMap<>(); ext.put("bizType", "purchase_approval"); ext.put("bizId", bizId); task.setExt(ext); request.setTask(task); return request; } private String generateToken(String bizId, String userId) { String payload = bizId + "|" + userId + "|" + System.currentTimeMillis(); return HmacUtils.hmacSha256Hex("your_app_secret", payload); } }

DingTalkTokenManager.java(token管理器)

@Component public class DingTalkTokenManager { @Autowired private RedisTemplate<String, Object> redisTemplate; @Value("${dingtalk.app-key}") private String appKey; @Value("${dingtalk.app-secret}") private String appSecret; @Value("${dingtalk.api-url}") private String apiUrl; private static final String TOKEN_KEY = "dingtalk_access_token"; /** * 获取access_token,自动刷新 */ public String getAccessToken() { String token = (String) redisTemplate.opsForValue().get(TOKEN_KEY); if (token == null || isTokenExpired(token)) { token = refreshAccessToken(); } return token; } private boolean isTokenExpired(String token) { // 解析token中的expires_in字段(实际token是JSON字符串,需解析) // 简化版:假设我们存的是{"access_token":"xxx","expires_in":7200,"time":1725000000000} // 这里用Redis TTL判断更可靠 Long ttl = redisTemplate.getExpire(TOKEN_KEY, TimeUnit.SECONDS); return ttl == null || ttl < 300; // 剩余300秒时刷新 } private String refreshAccessToken() { String url = apiUrl + "/gettoken?appkey=" + appKey + "&appsecret=" + appSecret; try { ResponseEntity<TokenResponse> response = restTemplate.getForEntity(url, TokenResponse.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null && response.getBody().getErrcode() == 0) { String newToken = response.getBody().getAccessToken(); // 设置Redis,TTL为7000秒(2小时减200秒缓冲) redisTemplate.opsForValue().set(TOKEN_KEY, newToken, Duration.ofSeconds(7000)); return newToken; } } catch (Exception e) { log.error("刷新钉钉token失败", e); } throw new RuntimeException("获取钉钉token失败"); } }

TodoRequest.java(请求DTO)

@Data public class TodoRequest { private String userId; private String title; private String url; private String pcUrl; private Long dueTime; private Long remindTime; private Integer remindType; private Task task; @Data public static class Task { private String title; private String url; private String pcUrl; private Long dueTime; private Long remindTime; private Integer remindType; private Map<String, String> ext; } }

TokenResponse.java(token响应DTO)

@Data public class TokenResponse { private int errcode; private String errmsg; private String access_token; private long expires_in; // 单位:秒 }

TodoResponse.java(待办响应DTO)

@Data public class TodoResponse { private int errcode; private String errmsg; private String processInstanceId; // 钉钉生成的流程实例ID }

4.3 异常处理与重试机制:让推送真正可靠

钉钉API不是100%可用,网络抖动、限流、token过期都会导致失败。我们设计了三级重试:

第一级:本地内存重试(立即)
在createTodo()方法内,捕获RestClientException后,立即重试2次,间隔100ms:

for (int i = 0; i < 3; i++) { try { ResponseEntity<TodoResponse> response = restTemplate.postForEntity(url, request, TodoResponse.class); if (success(response)) return true; } catch (RestClientException e) { if (i == 2) throw e; // 最后一次失败才抛出 Thread.sleep(100); } }

第二级:Kafka重试队列(异步)
若本地重试失败,将事件发到dingtalk-todo-retryTopic,消费者配置retry.backoff.ms=60000(1分钟重试),最大重试次数3次。

第三级:人工干预队列(兜底)
三次重试仍失败,投递到dingtalk-todo-failedTopic,由告警服务监听,发送企业微信告警:“待办推送失败,bizId=CG-2024-08721,请人工处理”。运维可登录后台,用补偿脚本重推。

实操心得:不要迷信“一次成功”。我们统计过,线上环境约0.8%的推送会进入重试队列,其中92%在第一次重试就成功。把重试逻辑写死在代码里,比依赖外部调度更可控。

5. 常见问题与排查技巧实录

5.1 典型错误码速查表与根因分析

错误码错误信息根因分析解决方案
40001invalid credentialaccess_token失效或错误检查Redis中token值,调用/gettoken接口验证
40012app not existappkey或appsecret填写错误核对开发者后台的AppKey/AppSecret,注意大小写
40013invalid appkey应用未启用或未授权登录钉钉管理后台,检查应用状态和权限授权
40014invalid access_tokentoken被回收或格式错误清空Redis中dingtalk_access_token,重启服务
40026user not existuserId不存在于当前企业组织架构调用/user/get接口验证userId,检查是否离职
40032invalid urlurl非HTTPS或域名未备案用浏览器访问url,确认能打开且证书有效
40033invalid dueTimedueTime不是毫秒时间戳或已过期检查Java代码是否误用System.currentTimeMillis()/1000

特别注意40026错误:很多团队用数据库里的员工工号当userId,但钉钉的userId是ding_XXXXXXXXX格式的字符串。正确做法是:在员工入职时,调用钉钉/user/get_by_unionid接口,用员工手机号或邮箱查出真实userId,存入本地员工表。

5.2 调试技巧:如何快速定位推送失败?

技巧1:开启钉钉API Debug日志
在RestTemplate配置中加入拦截器:

@Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate(); restTemplate.setInterceptors(Collections.singletonList(new LoggingInterceptor())); return restTemplate; } public class LoggingInterceptor implements ClientHttpRequestInterceptor { @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { log.info("DingTalk API Request: {} {}", request.getMethod(), request.getURI()); log.info("Request Headers: {}", request.getHeaders()); log.info("Request Body: {}", new String(body, StandardCharsets.UTF_8)); ClientHttpResponse response = execution.execute(request, body); log.info("DingTalk API Response Status: {}", response.getStatusCode()); log.info("Response Body: {}", StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8)); return response; } }

注意:生产环境要关闭此日志,避免泄露敏感信息。

技巧2:用Postman模拟调用
把access_token和待办JSON体复制到Postman,直接调用https://oapi.dingtalk.com/topapi/processinstance/create。如果Postman能成功,说明代码逻辑没问题,问题在Java环境(如SSL证书、代理设置)。

技巧3:检查钉钉管理后台的API调用量
登录钉钉开发者后台 → 应用详情 → 「API调用统计」,查看processinstance/create接口的调用成功率。如果成功率骤降,大概率是企业侧配置变更(如权限回收、应用停用)。

5.3 性能优化实战:从200ms到45ms的三次迭代

第一次优化:连接池配置
初始用默认RestTemplate,单次调用耗时200ms。改为:

@Bean public RestTemplate restTemplate() { HttpClient httpClient = HttpClientBuilder.create() .setMaxConnTotal(200) .setMaxConnPerRoute(100) .setConnectionTimeToLive(60, TimeUnit.SECONDS) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }

耗时降至120ms。

第二次优化:GZIP压缩
钉钉API响应体较大(含用户头像等),开启GZIP:

HttpClient httpClient = HttpClientBuilder.create() .addInterceptorFirst(new HttpRequestInterceptor() { @Override public void process(HttpRequest request, HttpContext context) throws HttpException, IOException { request.addHeader("Accept-Encoding", "gzip"); } }) .build();

耗时降至85ms。

第三次优化:DNS缓存
oapi.dingtalk.comDNS解析偶尔超时,加本地缓存:

@Bean public RestTemplate restTemplate() { // 自定义DNS解析器,缓存300秒 DnsResolver dnsResolver = new InetSocketAddressDnsResolver( Collections.singletonMap("oapi.dingtalk.com", InetAddress.getByName("118.31.11.11"))); HttpClient httpClient = HttpClientBuilder.create() .setDnsResolver(dnsResolver) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); }

最终稳定在45±5ms。

实操心得:性能优化不是堆参数,而是找到瓶颈。我们用Arthas trace发现,70%耗时在DNS解析和SSL握手,针对性优化后效果立竿见影。

6. 监控与可观测性:让推送服务不再黑盒

6.1 关键指标埋点与告警阈值

我们监控4个黄金指标:

指标采集方式告警阈值业务含义
dingtalk.todo.push.success.ratePrometheus Counter<99.5%整体成功率,低于此值说明API或网络异常
dingtalk.todo.push.latency.p95Micrometer Timer>500ms95%请求耗时,反映服务性能
dingtalk.todo.retry.countKafka消费组lag>100重试队列积压,预示下游处理瓶颈
dingtalk.todo.token.refresh.failuresLog日志grep>3次/小时token刷新失败,可能导致批量推送中断

告警规则用Prometheus Alertmanager配置:

- alert: DingTalkTodoPushFailureRateHigh expr: 100 * (1 - rate(dingtalk_todo_push_success_total[1h]) / rate(dingtalk_todo_push_total[1h])) > 0.5 for: 5m labels: severity: critical annotations: summary: "钉钉待办推送失败率过高" description: "过去1小时失败率{{ $value }}%,请立即检查" - alert: DingTalkTodoPushLatencyHigh expr: histogram_quantile(0.95, rate(dingtalk_todo_push_latency_seconds_bucket[1h])) > 0.5 for: 10m labels: severity: warning annotations: summary: "钉钉待办推送延迟过高" description: "p95延迟{{ $value }}秒,高于阈值0.5秒"

6.2 日志规范:让每一次失败都有迹可循

我们强制要求日志包含5个字段:

  • bizId:业务单据ID(如订单号)
  • userId:钉钉用户ID
  • traceId:全链路追踪ID
  • apiUrl:调用的钉钉API地址
  • responseCode:HTTP状态码

日志样例:

2024-08-20 14:22:31.234 ERROR [dingtalk-todo-service,,] 12345 --- [kafka-consumer-1] c.c.d.s.DingTalkTodoService : 钉钉待办推送失败 bizId=CG-2024-08721 userId=ding_abc123456 traceId=abc123-apiUrl=https://oapi.dingtalk.com/topapi/processinstance/create responseCode=40026

提示:用Logback的MDC(Mapped Diagnostic Context)注入这些字段,比拼接字符串更高效、更易过滤。

7. 扩展与演进:从单点推送走向协同中枢

7.1 待办状态同步:让钉钉和业务系统保持一致

当前方案只解决“推送”,但用户在钉钉里点击“已完成”后,业务系统并不知情。我们扩展了双向同步:

  • 钉钉回调配置:在开发者后台开启“待办任务状态变更”事件订阅,钉钉会POST到我们的/dingtalk/todo/status接口。
  • 回调验签:钉钉回调带signature和timestamp,用appsecret验证签名,防止伪造。
  • 状态更新:收到status=completed后,调用业务系统API更新订单状态为“已审批”。

这样就形成了闭环:业务系统 → 推送待办 → 用户处理 → 钉钉回调 → 业务系统更新。

7.2 多端一致性:待办在钉钉、企微、飞书同时存在?

有客户提出:“能不能一份待办,同时推送到钉钉、企微、飞书?” 我们抽象出TodoPublisher接口:

public interface TodoPublisher { boolean publish(String platform, String userId, Todo todo); } @Component public class DingTalkTodoPublisher implements TodoPublisher { ... } @Component public class WeComTodoPublisher implements TodoPublisher { ... } @Component public class FeiShuTodoPublisher implements TodoPublisher { ... }

业务层只调用todoPublisher.publish("dingtalk", userId, todo),具体实现由Spring根据platform自动注入。这样新增平台只需加一个实现类,零侵入现有代码。

最后分享一个小技巧:钉钉待办的title长度限制是128字符,但用户常填超长标题。我们在推送前用StringUtils.substring(title, 0, 125) + "..."截断,并在ext里存完整标题。H5页面加载时,用完整标题替换页面标题,既满足API限制,又不丢失信息。

返回列表