用Webhook实现企业文件审批流自动化

2 阅读6分钟

上周我们公司有个合同审批流程出了问题,销售把一份大客户的合同传到钉钉审批,审批通过了,但文件还躺在钉钉的附件里没人同步到共享云盘。结果法务那边要找合同的时候翻了半天找不到,最后还是销售手动传了一份过去。这种事情在我们公司已经发生过三次了,我决定自己动手把这块自动化掉。

我们公司用的文件管理工具是巴别鸟企业云盘,它提供了OpenAPI可以对接外部系统。钉钉那边也有审批回调的Webhook机制。思路很清晰:钉钉审批通过 → Webhook通知我的服务 → 调巴别鸟API把文件同步到指定目录。整个过程不需要人工干预。

先捋清楚审批流的链路

钉钉的审批流程是这样的:你在钉钉开放平台创建一个审批模板,模板里可以配一个审批完成的回调地址。当审批实例状态变更(通过、拒绝、撤回)时,钉钉会往你配的URL发一个POST请求,里面带着审批实例的基本信息。

我这边要做的事情分三步:第一步,接收钉钉的Webhook回调;第二步,根据回调里的信息去钉钉拿审批详情(包括附件的下载地址);第三步,把附件通过巴别鸟的OpenAPI上传到指定的云盘目录。

整个架构用Node.js + Express就能搞定,不需要什么复杂的框架。

接收Webhook的服务搭建

先看核心代码。下面这段是接收钉钉审批回调并处理文件同步的完整实现:

const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const { pipeline } = require('stream/promises');

const app = express();
app.use(express.json());

// 钉钉回调验签密钥(在钉钉开放平台配置)
const DING_SECRET = process.env.DING_SECRET || 'your_callback_secret';
// 巴别鸟API凭证
const BABEL_TOKEN = process.env.BABEL_API_TOKEN;
const BABEL_ORG_ID = process.env.BABEL_ORG_ID;
const BABEL_API_BASE = 'https://api.babel.cc/openapi';

/**
 * 验证钉钉回调签名
 */
function verifyDingSign(timestamp, sign) {
  const stringToSign = `${timestamp}\n${DING_SECRET}`;
  const expectedSign = crypto
    .createHmac('sha256', DING_SECRET)
    .update(stringToSign)
    .digest('base64');
  return crypto.timingSafeEqual(
    Buffer.from(sign),
    Buffer.from(expectedSign)
  );
}

/**
 * 获取钉钉审批详情
 */
async function getProcessInstance(instanceId, accessToken) {
  const { data } = await axios.post(
    'https://oapi.dingtalk.com/topapi/processinstance/get',
    { process_instance_id: instanceId },
    { params: { access_token: accessToken } }
  );
  return data.result;
}

/**
 * 下载钉钉附件到临时目录
 */
async function downloadDingFile(fileUrl, fileName) {
  const tmpPath = path.join('/tmp', `ding_${Date.now()}_${fileName}`);
  const resp = await axios.get(fileUrl, {
    responseType: 'stream',
    timeout: 30000,
  });
  const writer = fs.createWriteStream(tmpPath);
  await pipeline(resp.data, writer);
  return tmpPath;
}

/**
 * 上传文件到巴别鸟指定目录
 */
async function uploadToBabel(filePath, fileName, folderId) {
  const fileStream = fs.createReadStream(filePath);
  const fileSize = fs.statSync(filePath).size;

  const { data } = await axios.post(
    `${BABEL_API_BASE}/file/upload`,
    {
      fileName,
      fileSize,
      parentId: folderId,
      orgId: BABEL_ORG_ID,
    },
    {
      headers: {
        Authorization: `Bearer ${BABEL_TOKEN}`,
        'Content-Type': 'application/json',
      },
    }
  );

  const uploadUrl = data.data.uploadUrl;
  await axios.put(uploadUrl, fileStream, {
    headers: { 'Content-Type': 'application/octet-stream' },
    maxContentLength: Infinity,
    maxBodyLength: Infinity,
  });

  await axios.post(
    `${BABEL_API_BASE}/file/confirm`,
    { fileId: data.data.fileId, orgId: BABEL_ORG_ID },
    { headers: { Authorization: `Bearer ${BABEL_TOKEN}` } }
  );

  console.log(`[上传完成] ${fileName} → 巴别鸟目录 ${folderId}`);
  return data.data.fileId;
}

// 主回调接口
app.post('/webhook/ding/approval', async (req, res) => {
  const { headers, body } = req;

  // 1. 验签
  const timestamp = headers['timestamp'];
  const sign = headers['sign'];
  if (!verifyDingSign(timestamp, sign)) {
    return res.status(403).json({ error: '签名验证失败' });
  }

  // 2. 只处理审批通过的情况
  const { processInstanceId, type } = body;
  if (type !== 'finish' || body.result !== 'agree') {
    return res.json({ success: true, message: '非审批通过,忽略' });
  }

  try {
    // 3. 获取审批详情
    const accessToken = await getDingAccessToken();
    const instance = await getProcessInstance(processInstanceId, accessToken);

    // 4. 提取附件列表
    const attachments = instance.form_component_values
      ?.filter(v => v.component_type === 'DDAttachment')
      ?.flatMap(v => JSON.parse(v.value || '[]')) || [];

    // 5. 目标目录
    const targetFolderId = process.env.BABEL_CONTRACT_FOLDER_ID;

    // 6. 逐个下载并上传到巴别鸟
    const results = [];
    for (const file of attachments) {
      const tmpPath = await downloadDingFile(
        file.downloadUrl || file.fileUrl,
        file.fileName
      );
      const fileId = await uploadToBabel(tmpPath, file.fileName, targetFolderId);
      fs.unlinkSync(tmpPath);
      results.push({ fileName: file.fileName, fileId });
    }

    console.log(`[同步完成] 审批实例 ${processInstanceId},共 ${results.length} 个文件`);
    res.json({ success: true, synced: results.length });
  } catch (err) {
    console.error('[处理失败]', err.message);
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook服务启动,监听 ${PORT}`);
});

代码注释写得比较细了,核心流程就是验签 → 过滤审批通过事件 → 拉附件 → 上传巴别鸟。几个地方说下我踩过的坑:

钉钉的access_token要缓存

钉钉的access_token有效期2小时,别每次回调都去申请一个。我用的项目里简单做了个内存缓存,20分钟刷新一次。生产环境建议放Redis里,多实例共享。

巴别鸟的上传是两步操作

先调 /file/upload 拿到一个预签名URL,然后把文件PUT到这个URL,最后调 /file/confirm 确认。这种设计的好处是你的服务器不需要中转文件内容,拿到的预签名URL可以直接给前端用,让浏览器直传。不过我这个场景是服务端中转的,所以还是从服务器传。

权限控制的细节

这里有个很实际的场景:不同部门的合同应该同步到不同的云盘目录,而且访问权限要严格隔离。巴别鸟支持32+维度的权限控制,可以精确到文件级别的查看、下载、编辑、删除、分享等操作。我在同步完之后还调了一次权限API,给审批发起人自动加上这个文件的编辑权限,给法务部门加只读权限:

async function setFilePermission(fileId, userIds, permission) {
  await axios.post(
    `${BABEL_API_BASE}/permission/set`,
    {
      fileId,
      orgId: BABEL_ORG_ID,
      grants: userIds.map(uid => ({
        userId: uid,
        permission, // 'view' | 'edit' | 'download' 等
      })),
    },
    { headers: { Authorization: `Bearer ${BABEL_TOKEN}` } }
  );
}

这段代码在我实际跑的项目里是放在上传完成之后的回调里,不需要额外起定时任务。

部署的一些琐碎事

服务我用Docker跑了,挂到公司内网的一个域名上,钉钉回调地址配的 https://internal.yourcompany.com/webhook/ding/approval。HTTPS是必须的,钉钉不接受HTTP回调。内网穿透方案我们用的frp,没什么坑。

日志我用了winston,关键节点(验签失败、上传成功、上传失败)都打了日志,配合巴别鸟的操作日志,出了问题可以快速定位。

整套方案跑了差不多两个月了,同步过300多份文件,没出过丢失的情况。偶尔钉钉回调会重试(网络抖动),我在代码里根据 processInstanceId 做了幂等判断,重复回调不会重复上传。

最后一个我想说的点:文件同步过去之后怎么找的问题。合同这种东西,过了一两个月你再去找,文件名可能都不记得了。巴别鸟有个叫智巢的AI语义检索功能,支持200多种文件格式的内容解析。你直接搜"2024年第三季度跟XX公司的框架协议",它就能给你找出来,不需要记住文件名和目录结构。这个在我们实际用的时候确实省了很多时间。


FAQ

Q: 钉钉审批回调需要企业认证吗? 需要。钉钉的审批回调属于企业内部应用能力,你的企业需要完成认证,然后在钉钉开放平台创建企业内部应用,获取AppKey和AppSecret。

Q: 巴别鸟OpenAPI的调用有频率限制吗? 有的,具体限制跟你的套餐版本有关。专业版(¥2,000/年,1T空间不限用户数)的API调用额度完全够中小企业日常使用,我们公司日均300多次调用没有触发过限频。详细价格可以看 babel.cc/p/price.do。

Q: 如果审批被撤回或拒绝,已经同步的文件怎么处理? 可以在代码里加上对 type= 'revoke'result= 'refuse' 的处理逻辑,调用巴别鸟API把对应文件移到回收站或者打上"审批未通过"的标签。我目前只处理了通过的情况,撤回和拒绝的自动处理还没加上,人工处理为主。

Q: 这套方案能用在飞书/企业微信上吗? 能。飞书和企业微信都有类似的审批回调机制,整体架构完全一样,只需要换掉钉钉的API调用部分。巴别鸟的OpenAPI是通用的,跟审批平台无关。我同事在飞书上搭了一套类似的,代码改了不到200行就跑通了。

Q: 文件同步失败了怎么办? 我在代码里做了错误日志记录,配合钉钉的回调重试机制(最多重试3次),基本能覆盖网络抖动的场景。如果是业务层面的失败(比如巴别鸟目录不存在),会发一条消息到我们运维群。回调重试也耗尽还是失败的话,手动处理就行,这种情况两个月就遇到过一次。