文章目录

SmartOA 的审批流要推送到钉钉:审批发起后在钉钉群/单聊收到互动卡片,卡片上直接点「同意/拒绝」就能完成审批,状态实时回写。整个集成拆成五个阶段逐步落地(基础设施 → 卡片发送 → Stream 回调 → 待办同步 → 状态同步),每个阶段都有独立可验证的里程碑。这篇记录完整架构和踩坑。

一、整体架构

1
阶段 1 · 基础设施:dingtalk-stream 1.3.12 长连接依赖 + 卡片配置类 + @EnableAsync + getAppAccessToken 改 public
stream
↓
2
阶段 2 · 卡片发送:CardTemplateBuilder + DingTalkCardService + EventListener,审批发起即发卡片
card send
↓
3
阶段 3 · Stream 回调:DingTalkStreamConfig + CallbackListener,卡片按钮点击事件回流
callback
↓
4
阶段 4 · 待办同步:DingTalkTodoService,审批生成钉钉待办任务
todo
↓
5
阶段 5 · 状态同步:ProcessEvent / ProcessDeleteEvent 监听 → 更新卡片状态(已通过/已拒绝/已撤销)
status sync

二、阶段 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 再传回。

🚨 坑:雪花 ID > 2^53 进前端必须转 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 负责:

// 待办创建: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 解析

八、方法论:五阶段拆解的好处

  1. 每阶段独立可验证:先跑通发送,再回回调,每步都有明确验收点,出问题知道是哪一段
  2. 基础设施先行:长连接/配置类先就位,后续阶段不重复搭环境
  3. 事件解耦:状态同步走事件监听,审批主流程不依赖钉钉可用性
  4. 防重放前置:回调类接口从第一天就带一次性 token,别等上线再补
✅ 最终成果:审批全流程钉钉化——卡片即点即批、待办同步、状态实时回写、详情页印章展示,五阶段各自独立可验证,全程零公网回调地址。

评 论