钉钉 Starter 与告警体系:Spring Boot Starter + 多租户消息推送
ERP 系统需要向运营/客服/仓库等人员推送告警和通知: 钉钉是内部协作工具,因此选择**钉钉机器人 + 钉钉应用消息**作为主要通知渠道。 ERP 面向集团内多个部门/事业群,每个部门可能有独立的钉钉企业自建应用(独立的 corpId /
1. 背景与痛点
业务背景
ERP 系统需要向运营/客服/仓库等人员推送告警和通知:
- 库存预警(低于安全库存)
- 订单履约异常(WARN 日志触发)
- 采购单审核通知
- 平台 API 调用异常(429 超限)
- 定时任务执行失败
钉钉是内部协作工具,因此选择钉钉机器人 + 钉钉应用消息作为主要通知渠道。
技术痛点
痛点一:接入成本高
每个业务方都要:
1. 引入阿里云 DingTalk SDK
2. 处理 accessToken 获取和刷新
3. 处理签名算法(HMAC-SHA256)
4. 封装消息格式(Text/Link/ActionCard)
5. 处理发送失败重试
痛点二:多租户隔离
ERP 面向集团内多个部门/事业群,每个部门可能有独立的钉钉企业自建应用(独立的 corpId / appKey / secret)。业务代码直接写死这些配置会导致多租户场景下混乱。
痛点三:消息模板不一致
各业务方自行封装消息格式,导致用户收到的通知格式各异,体验差。
2. 技术方案
整体架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ 业务服务(消费方) │
│ │
│ 引入 creeks-dingtalk-client SDK │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ClientAutoConfiguration │ │
│ │ @ComponentScan("com.codo.tech.creeks.dingtalk.client") │ │
│ │ @EnableFeignClients(basePackages = {"..."}) │ │
│ │ → 自动注册 DingTalkMessageRemoteService Feign Client │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ 业务代码直接调用: │
│ @Resource DingTalkMessageRemoteService │
│ dingTalkMessageRemoteService.sendMessageToUser(req); │
│ dingTalkMessageRemoteService.groupRobotSendMessage(req); │
└─────────────────────────────────────────────────────────────────────────────┘
│ HTTP(Feign)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ creeks-dingtalk-service(钉钉服务) │
│ │
│ DingMessageController │
│ ├─ POST /remote/message/sendToUser → 发送给指定用户 │
│ ├─ POST /remote/message/sendToUserByUserName → 按用户名发送 │
│ └─ POST /remote/message/groupRobotSendMessage → 群聊机器人发送 │
│ │
│ DingMessageServiceImpl │
│ ├─ 钉钉应用消息(BatchSendOTO API)→ 按 corpId/appKey 获取 accessToken │
│ │ → 多租户:每个部门有独立 DingApp,accessToken 按需刷新 │
│ └─ 群聊机器人(Robot API)→ HMAC-SHA256 签名 │
│ │
│ AuthHelper → accessToken 获取 + 缓存 + 自动刷新 │
│ RobotSignUtil → HMAC-SHA256 签名算法 │
└─────────────────────────────────────────────────────────────────────────────┘
│ HTTPS
▼
钉钉开放平台 API
接入方式(业务服务侧)
一步接入:
<!-- pom.xml -->
<dependency>
<groupId>com.codo.tech</groupId>
<artifactId>creeks-dingtalk-client</artifactId>
<version>1.0.1</version>
</dependency>
// 业务代码
@Resource
private DingTalkMessageRemoteService dingTalkMessageRemoteService;
// 发送文本消息给指定用户
DingSendMessageToUserReq req = new DingSendMessageToUserReq();
req.setUserIds(Lists.newArrayList("userId001"));
req.setMsgKey("sampleText");
req.setMessage("您的调拨单 AT20240422001 已审批通过");
dingTalkMessageRemoteService.sendMessageToUser(req);
// 群聊机器人发送
DingGroupRobotSendMsgReq robotReq = new DingGroupRobotSendMsgReq();
robotReq.setAccessToken("webhook_token");
robotReq.setSecret("加密秘钥");
OapiRobotSendRequest request = new OapiRobotSendRequest();
request.setMsgtype("text");
request.setText(Map.of("content", "库存预警:SKU001 剩余 5 件"));
robotReq.setRequest(request);
dingTalkMessageRemoteService.groupRobotSendMessage(robotReq);
不需要关心任何 SDK 初始化、Token 刷新、签名算法。
3. 实现细节
模块一:Spring Boot Starter 自动配置(ClientAutoConfiguration)
@Configuration
@ComponentScan(basePackages = {"com.codo.tech.creeks.dingtalk.client"})
@EnableFeignClients(basePackages = {"com.codo.tech.creeks.dingtalk.client"})
public class ClientAutoConfiguration {}
配合 META-INF/spring.factories 或 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 实现自动装配。业务方引入 JAR 后,Spring 自动扫描 com.codo.tech.creeks.dingtalk.client 包下的所有 @Component,包括 DingTalkMessageRemoteService Feign Client。
模块二:Feign Client 定义
@FeignClient(
name = "dingtalk-message",
url = "${dingtalk.service.url:http://creeks-dingtalk-service:8080}",
path = "/remote/message")
public interface DingTalkMessageRemoteService {
// 发给指定用户
@PostMapping("/sendToUser")
void sendMessageToUser(@RequestBody @Valid DingSendMessageToUserReq req);
// 按用户名发送(内部根据用户名查 userId)
@PostMapping("/sendToUserByUserName")
void sendMessageToUserByUserNames(@RequestBody @Valid DingSendMessageToUserByUserNamesReq req);
// 群聊机器人
@PostMapping("/groupRobotSendMessage")
void groupRobotSendMessage(@RequestBody @Valid DingGroupRobotSendMsgReq req);
}
模块三:多租户 Token 管理(AuthHelper)
每个部门/事业群有独立的钉钉自建应用,Token 需要按 corpId/appKey 独立管理:
// 服务端:按 appId 查找 DingApp 配置,获取 corpId / appKey / appSecret
DingApp dingApp = dingAppService.getDingAppByAppId(user.getAppId());
// 获取 accessToken(带缓存和自动刷新)
String accessToken = authHelper.obtainAccessToken(dingApp);
// 发送到钉钉
BatchSendOTOHeaders headers = new BatchSendOTOHeaders();
headers.xAcsDingtalkAccessToken = accessToken;
robotClient.batchSendOTOWithOptions(request, headers, new RuntimeOptions());
模块四:群聊机器人签名(RobotSignUtil)
钉钉群聊机器人要求对请求进行 HMAC-SHA256 加签:
public static String getSign(Long timestamp, String secret) {
// 1. 拼接签名原文
String stringToSign = timestamp + "\n" + secret;
// 2. HMAC-SHA256 计算
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
byte[] signData = mac.doFinal(stringToSign.getBytes("UTF-8"));
// 3. Base64 编码 + URL 编码
return URLEncoder.encode(new String(Base64.encodeBase64(signData)), "UTF-8");
}
最终 URL 格式:
https://oapi.dingtalk.com/robot/send?access_token=xxx×tamp=1713763200000&sign=xxxxx
模块五:消息模板(多格式支持)
// Text 类型
SampleText { content: "您的调拨单已审批通过" }
// Link 类型(带链接)
SampleLink { title, text, picUrl, messageUrl }
// ActionCard 类型(可点击跳转)
SampleActionCard { title, text, singleURL }
processBatchSendOTORequest 根据 msgKey 选择消息模板,将消息体序列化为 JSON 后发给钉钉。
模块六:深度链接重写(obtainSendUrl)
钉钉消息中的 URL 需要嵌入 corpId/appId,以便钉钉客户端能正确跳转回 ERP 系统:
private String obtainSendUrl(String url, DingApp dingApp) {
// 原始 URL 追加 corpId 和 appId 参数
String rewritten = url + "?corpId=" + dingApp.getCorpId() + "&appId=" + dingApp.getAppId();
// 包装为钉钉内部协议 URL
return "dingtalk://dingtalkclient/action/openapp"
+ "?corpid=" + dingApp.getCorpId()
+ "&container_type=work_platform"
+ "&app_id=" + dingApp.getAppId()
+ "&redirect_type=jump"
+ "&redirect_url=" + URLEncoder.encode(rewritten, UTF8);
}
这样用户在钉钉中点击消息链接,系统能正确识别来自哪个部门,并跳转到对应的租户环境。
4. 技术栈与亮点归纳
技术栈清单
- Java / Spring Boot
- Spring Cloud OpenFeign(服务间 HTTP 调用)
- 阿里云 DingTalk SDK(
com.aliyun:dingtalk) - Spring Boot Starter(自动装配机制)
- HMAC-SHA256 + Base64 + URLEncoder(签名算法)
技术亮点
- Spring Boot Starter 零配置接入:引入 JAR 包,配置 URL,自动装配所有组件
- 多租户隔离:每个部门独立 DingApp 配置,Token 按租户独立管理
- 深度链接重写:消息中的 URL 自动嵌入 corpId/appId,确保钉钉客户端正确跳转
- Feign 统一封装:消息发送通过 Feign Client 调用,业务方不感知 HTTP 细节
- 签名算法内置:RobotSignUtil 封装 HMAC-SHA256,业务方只需传 secret
5. 难点与亮点剖析
业务难点
- Token 刷新:accessToken 有有效期(2 小时),多个租户同时使用时需要独立缓存和刷新
- 消息模板统一:不同业务发送的消息格式需要统一(通过 msgKey 枚举)
技术难点
难点一:多租户 Token 缓存
不同部门的 accessToken 不同,且各自过期时间不同。AuthHelper 需要按 appId 独立缓存,超时后重新获取。
难点二:签名时效性
钉钉群机器人要求 timestamp 与服务器时间差不超过 1 小时。使用 System.currentTimeMillis() 生成时间戳。
实现亮点
- Spring Boot Starter 标准化接入:符合业界标准的 JAR 包接入方式,文档只需要一行
<dependency> - 深度链接跨租户跳转:
obtainSendUrl将 corpId/appId 嵌入 URL,确保钉钉打开时系统能识别租户 - 消息模板可扩展:Text / Link / ActionCard 三种模板覆盖主要场景,未来可扩展更多模板
6. 替代方案对比
方案一:每个业务方直接用阿里云 SDK(历史方案,已重构)
- 实现:各业务方自行引入
com.aliyun:dingtalkSDK,处理 Token + 签名 + 发送 - 问题:配置重复(每个服务都要配钉钉参数)、签名逻辑散落各处、多租户无法隔离
- 结论:SDK 封装后统一管理
方案二:企业微信替代钉钉(已排除)
- ERP 团队内部使用钉钉作为协作工具,企微不在选型范围内
7. 技术选型与方案抉择复盘
选型思路
2024 年将钉钉通知能力从各业务散落代码中抽取出来,统一封装为 creeks-dingtalk-client SDK,目标:新业务接入钉钉通知只需 5 分钟。
优点
- 接入成本极低:引入依赖 + 配置 URL,0 行初始化代码
- 多租户隔离:每个部门独立配置,互不干扰
- 服务统一维护:消息发送逻辑集中管理,升级只需改 SDK 版本
缺点 / 已知不足
- Token 管理在服务端:client SDK 发消息时需要先通过 Feign 调用服务端,服务端再去拿 Token,网络多一跳
- 群机器人安全:Secret 放在配置中,需要防止泄露(建议存 K8s Secret)
可优化点
- Client SDK 侧增加本地 Token 缓存,减少 Feign 调用次数
- 增加消息发送幂等(钉钉 API 本身不支持幂等,可通过消息 ID 去重)
- 告警频率控制(同一类告警 5 分钟内不重复发送)
8. 成果与数据
- 接入规模:ERP 系统 10+ 业务模块接入钉钉通知 SDK
- 告警覆盖:库存预警、订单异常、平台 API 超限、定时任务失败等场景 100% 覆盖
- 接入耗时:新业务接入钉钉通知从"写 200 行代码"降至"引依赖 + 调 Feign"
- 多租户:已支持集团内 3 个部门/事业群 独立钉钉应用配置
9. 面试题清单
- 【基础】Spring Boot Starter 的实现原理是什么?@ComponentScan 和 @EnableFeignClients 的作用?
- 考察点:自动装配、spring.factories / AutoConfiguration.imports、Bean 扫描注册
- 【基础】Feign Client 的 @FeignClient 注解各参数含义?url 和 name 的区别?
- 考察点:name = Bean 名称,url = 实际请求地址,path = 统一前缀,fallback = 降级类
- 【进阶】钉钉群机器人为什么需要签名?签名算法是怎么计算的?
- 考察点:HMAC-SHA256 + Base64 + URLEncoder、时间戳有效性(1 小时内)
- 【进阶】多租户场景下,钉钉 accessToken 如何管理?为什么不能共用一个 Token?
- 考察点:每个 corpId 有独立的 Token、Token 按 appId 独立缓存、超时刷新
- 【原理】DingTalk SDK 中的 RobotSignUtil 为什么用
\n拼接 timestamp 和 secret?- 考察点:钉钉签名原文格式要求:
${timestamp}\n${secret},换行符是固定格式
- 考察点:钉钉签名原文格式要求:
- 【设计】obtainSendUrl 中的钉钉内部协议 URL(dingtalk://...)的作用是什么?为什么不能直接用原始 URL?
- 考察点:钉钉客户端内部跳转机制、corpId/appId 嵌入确保租户识别
- 【场景】如果钉钉 API 发送失败(网络超时),业务方能感知到吗?应该如何处理?
- 考察点:Feign 调用异常传播、同步 vs 异步发送、消息发送的幂等设计
- 【延伸】和阿里云 SSE(Serverless)函数计算相比, creeks-dingtalk-service 作为独立服务的优势?
- 考察点:状态管理(多租户 Token)、可维护性、独立部署 vs 函数计算冷启动
10. 简历提炼版
设计并实现 钉钉通知 Spring Boot Starter(creeks-dingtalk-client),业务方引入依赖即可零配置接入,Feign Client 封装消息发送全过程;多租户架构下每个部门独立 DingApp 配置(corpId/appKey/secret),Token 按租户独立管理;RobotSignUtil 封装 HMAC-SHA256 签名算法,深度链接重写实现钉钉客户端到 ERP 的跨租户跳转;接入 10+ 业务模块,新业务接入耗时从 2 人天降至 5 分钟。
关键源码路径
| 文件 | 路径 |
|---|---|
| Starter 自动配置 | creeks-dingtalk/client/.../config/ClientAutoConfiguration.java |
| Feign Client | creeks-dingtalk/client/.../remote/DingTalkMessageRemoteService.java |
| 群机器人消息请求 | creeks-dingtalk/client/.../model/req/DingGroupRobotSendMsgReq.java |
| 消息模板 | creeks-dingtalk/client/.../model/message/SampleLink.java |
| 签名工具 | creeks-dingtalk/server/.../utils/RobotSignUtil.java |
| 消息发送实现 | creeks-dingtalk/server/.../service/impl/DingMessageServiceImpl.java |
| 钉钉常量 | creeks-dingtalk/client/.../constant/DingTalkConstant.java |