跳到正文
Kale的数字花园
目录 文章
/7 分钟/7 阅读/0 评论

钉钉 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&timestamp=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() 生成时间戳。

实现亮点

  1. Spring Boot Starter 标准化接入:符合业界标准的 JAR 包接入方式,文档只需要一行 <dependency>
  2. 深度链接跨租户跳转:obtainSendUrl 将 corpId/appId 嵌入 URL,确保钉钉打开时系统能识别租户
  3. 消息模板可扩展:Text / Link / ActionCard 三种模板覆盖主要场景,未来可扩展更多模板

6. 替代方案对比

方案一:每个业务方直接用阿里云 SDK(历史方案,已重构)

  • 实现:各业务方自行引入 com.aliyun:dingtalk SDK,处理 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. 面试题清单

  1. 【基础】Spring Boot Starter 的实现原理是什么?@ComponentScan 和 @EnableFeignClients 的作用?
    • 考察点:自动装配、spring.factories / AutoConfiguration.imports、Bean 扫描注册
  2. 【基础】Feign Client 的 @FeignClient 注解各参数含义?url 和 name 的区别?
    • 考察点:name = Bean 名称,url = 实际请求地址,path = 统一前缀,fallback = 降级类
  3. 【进阶】钉钉群机器人为什么需要签名?签名算法是怎么计算的?
    • 考察点:HMAC-SHA256 + Base64 + URLEncoder、时间戳有效性(1 小时内)
  4. 【进阶】多租户场景下,钉钉 accessToken 如何管理?为什么不能共用一个 Token?
    • 考察点:每个 corpId 有独立的 Token、Token 按 appId 独立缓存、超时刷新
  5. 【原理】DingTalk SDK 中的 RobotSignUtil 为什么用 \n 拼接 timestamp 和 secret?
    • 考察点:钉钉签名原文格式要求:${timestamp}\n${secret},换行符是固定格式
  6. 【设计】obtainSendUrl 中的钉钉内部协议 URL(dingtalk://...)的作用是什么?为什么不能直接用原始 URL?
    • 考察点:钉钉客户端内部跳转机制、corpId/appId 嵌入确保租户识别
  7. 【场景】如果钉钉 API 发送失败(网络超时),业务方能感知到吗?应该如何处理?
    • 考察点:Feign 调用异常传播、同步 vs 异步发送、消息发送的幂等设计
  8. 【延伸】和阿里云 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
相关阅读

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。