文章目录
SmartOA 的审批流要推送到钉钉:审批发起后在钉钉群/单聊收到互动卡片,卡片上直接点「同意/拒绝」就能完成审批,状态实时回写。整个集成拆成五个阶段逐步落地(基础设施 → 卡片发送 → Stream 回调 → 待办同步 → 状态同步),每个阶段都有独立可验证的里程碑。这篇记录完整架构和踩坑。
一、整体架构
二、阶段 1:dingtalk-stream 长连接
钉钉互动卡片回调走 Stream 模式(WebSocket 长连接),不需要公网回调地址,内网也能接。接入点:
// pom.xml 依赖
<dependency>
<groupId>com.aliyun.dingtalk</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
注意:钉钉 SDK 强制依赖 fastjson2(DingTalkStreamConfig 里 5 处),这是 SDK 回调类型强绑定的豁免项——全项目唯一保留的第三方残留,其余 JSON 一律 Jackson。
三、阶段 2:卡片发送
审批发起后,用 CardTemplateBuilder 构建互动卡片模板,DingTalkCardService 发送到审批人:
// 卡片发送核心流程
public void sendApprovalCard(FlowTask task, Employee approver) {
// 1. 构建卡片:标题 + 表单字段(自动映射中文)+ 同意/拒绝按钮
CardTemplateBuilder builder = CardTemplateBuilder.create()
.title(approvalTitle(task))
.field("申请人", task.getApplicantName())
.field("类型", task.getFlowName())
.action("同意", "approve")
.action("拒绝", "reject");
// 2. 发送
dingTalkCardService.sendToUser(approver.getDingUserId(), builder.build());
}
联调坑:雪花 ID 精度丢失。后端用雪花 ID 做审批单主键(19 位),前端 JS 的 Number 只能精确表示 2^53,JSON 序列化后精度丢失——审批单 ID 对不上。修复:主键序列化为 String(Jackson ToStringSerializer 或 VO 层转 String),前端拿字符串 ID 再传回。
四、阶段 3:Stream 回调——卡片按钮点击
审批人在钉钉点「同意/拒绝」,通过 Stream 长连接回调到后端:
// CallbackListener:处理卡片按钮回调
public class CallbackListener implements CardCallbackListener {
@Override
public void onCardCallback(CardCallbackRequest request) {
String action = request.getParams().get("action"); // approve / reject
String taskId = request.getParams().get("taskId");
// 校验:一次性 token 防重放(Redis GETDEL 原子消费)
String token = request.getParams().get("token");
if (!redisService.getAndDelete(token).equals(token)) {
throw new BizException("回调已消费或无效");
}
// 调审批服务:同意/拒绝
flowTaskService.completeTask(taskId, action, approverId);
}
}
防重放设计:卡片里带一次性 token,点击后 Redis GETDEL 原子消费(取走即删),同一卡片点两次第二次直接拒绝。这是从「get + delete 两步操作」的 TOCTOU 竞态修过来的(A1 修复)——两步操作在并发下可以重复消费,GETDEL 一条命令原子完成。
五、阶段 4:钉钉待办同步
除了卡片,还要在钉钉「待办」里生成任务(用户习惯看待办列表)。DingTalkTodoService 负责:
- 审批创建 → 调钉钉待办 API 建任务,URL 指向 SmartOA 审批详情页
- 详情页用临时审批 Token 跳转:待办 URL 不带正式 token(防截获),而是带一次性临时 token,跳转后前端用临时 token 换正式会话(P0-B 优化)
- 修复坑:钉钉 webview 打开 URL 未登录会报错 → detailUrl 指向 SmartOA 首页 + 参数带 taskId,前端识别后引导登录再跳详情
- sessionStorage 改 localStorage:钉钉浏览器可能清 sessionStorage,待办跳转后丢失上下文(fix: sessionStorage→localStorage)
// 待办创建:URL 用临时 token,不暴露正式会话
String tempToken = UUID.randomUUID().toString().replace("-", "");
redisService.set("approval:temp:" + tempToken, taskId, Duration.ofMinutes(30));
todoService.create(
approverId,
"【审批】" + task.getFlowName(),
detailUrl + "?taskId=" + taskId + "&token=" + tempToken
);
六、阶段 5:状态同步
审批在 SmartOA 内被处理(网页审批、其他审批人先批等)后,卡片必须跟着变状态。用事件监听解耦:
// 监听审批完成事件 → 更新卡片为「已通过/已拒绝」
@EventListener
public void onProcessEvent(ProcessEvent event) {
dingTalkCardService.updateCardStatus(
event.getTaskId(),
CardStatus.of(event.getAction()) // PASS → 已通过 / REJECT → 已拒绝
);
}
// 监听删除事件 → 卡片标记「已撤销」
@EventListener
public void onProcessDelete(ProcessDeleteEvent event) {
dingTalkCardService.updateCardStatus(event.getTaskId(), CardStatus.REVOKED);
}
审批详情页加印章样式(已通过/已拒绝/已撤销三种视觉状态),与卡片状态一致。这里补测很重要:事件体系 6 个发布端测试补强(P0-5),避免监听器注册了但事件没发出来。
七、联调踩坑汇总
| 坑 | 现象 | 修复 |
|---|---|---|
| 雪花 ID 精度丢失 | 点同意报「任务不存在」 | 主键序列化 String |
| 回调重放 | 同一卡片点两次两次生效 | Redis GETDEL 原子消费一次性 token |
| webview 未登录 | 钉钉内打开详情页报错 | detailUrl 指首页 + 引导登录再跳转 |
| sessionStorage 被清 | 待办跳转后上下文丢失 | 改 localStorage |
| LocalDateTime 序列化 | 钉钉待办「任务不存在」 | LocalDateTime→Date 兼容钉钉 SDK 解析 |
八、方法论:五阶段拆解的好处
- 每阶段独立可验证:先跑通发送,再回回调,每步都有明确验收点,出问题知道是哪一段
- 基础设施先行:长连接/配置类先就位,后续阶段不重复搭环境
- 事件解耦:状态同步走事件监听,审批主流程不依赖钉钉可用性
- 防重放前置:回调类接口从第一天就带一次性 token,别等上线再补
评 论