上周我们公司有个合同审批流程出了问题,销售把一份大客户的合同传到钉钉审批,审批通过了,但文件还躺在钉钉的附件里没人同步到共享云盘。结果法务那边要找合同的时候翻了半天找不到,最后还是销售手动传了一份过去。这种事情在我们公司已经发生过三次了,我决定自己动手把这块自动化掉。
我们公司用的文件管理工具是巴别鸟企业云盘,它提供了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次),基本能覆盖网络抖动的场景。如果是业务层面的失败(比如巴别鸟目录不存在),会发一条消息到我们运维群。回调重试也耗尽还是失败的话,手动处理就行,这种情况两个月就遇到过一次。