ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JS技术栈自研轻量审批引擎:从模型到工程实践

JS技术栈自研轻量审批引擎:从模型到工程实践

简介:JavaScript工作流与审批流资源,专注于利用JS及配套框架实现业务流程管理与多级审批。资源系统性覆盖工作流状态机设计、任务分配调度、用户审批界面、数据持久化及异常回退机制,并给出BPMN建模与最佳实践要点,适合Web前端、全栈开发人员以及需要轻量级审批方案的团队参考。整包共49个文件,以js脚本、html页面、xml流程定义、css样式为主,另含aspx/cs服务端代码和db数据库文件,用于前后端联动演示,压缩包仅69KB。已有1285人学习下载,内容编写注重实操,从流程模型到引擎执行均有示例支撑,可帮助读者快速理解JS工作流核心概念,并参照结构化的目录组织搭建自己的审批应用。

1. 审批流不是上重型 BPM:JS 技术栈为什么更适合先自研轻量引擎

在一线做中后台系统的工程师,几乎都躲不开“JS 工作流、审批流”这类需求:请假要审批、报销要审批、合同要过审,需求方开口永远是一句话——“帮我在系统里加个审批流”。但真正聊细节你会发现,他们想要的不是两张页面加一个按钮,而是能改流程、能查历史、能处理驳回和转交的一套小型流程系统。一个反直觉的结论是:在 JS 技术栈里,最稳的做法往往不是去接一套重型 BPM 产品,而是先花几天自研一个轻量审批引擎,把流程定义、实例、任务这三件事理顺。

这篇文章就围绕这个方向讲清楚:模型怎么设计、引擎怎么写、前端怎么接、上线后哪些坑会翻车,以及怎么给它加上超时催办这类进阶能力。适合正在做后台系统和 OA 类产品的全栈工程师,也适合准备做技术选型、想先评估自研成本的人。

2. 审批流模型与选型:流程定义、实例、任务,以及三条落地路径

审批流这个词容易让人一上来就找“工作流引擎”,但我的经验是先别急着写代码,先把三个概念理顺。审批流和普通 CRUD 最大的区别在于,它把“线路”和“车次”分开管理。这三层模型想清楚,后续的接口设计、数据库设计、前端渲染全都会变得很顺。

2.1 流程定义、流程实例与任务:三层模型先说清

流程定义是“线路”,描述一条审批路径由哪些节点组成、节点之间怎么连接、满足什么条件走哪个分支、每个节点的审批人怎么找。例如请假流程:发起 → 直属主管审批 →(请假天数大于等于 3 再走部门经理审批)→ 结束。它是一张可以被很多次发起复用的模板。

流程实例是“车次”,指某一次真实的发起。比如 A同学在某个下午提交了 5 天年假申请,这条实例会记录:谁发起的、表单数据是什么、当前走到哪个节点、历史上都经历了哪些决策。同一个定义可以被反复发起,每次发起都会生成一个独立的实例。

任务是“座位”,实例到达某个节点后产生的待办。单签节点是一个任务;会签节点会同时产生多个任务,需要多个人各自处理。用伪代码表达就是:

const instance = { id: 'inst_001', flowDefId: 'leave_flow_v1', status: 'running', // running | rejected | completed currentNodeId: 'manager', // 当前指向哪个节点 formData: { days: 5, reason: '回家办事' }, history: [], version: 1 // 用于乐观锁,后面避坑章节会讲 }; const task = { id: 'task_001', instanceId: 'inst_001', nodeId: 'manager', assignee: 'u_1024', // 当前该处理的人 status: 'pending' // pending | done };

这里要特别注意:instance 存的是“整条流程当前在哪”,task 存的是“某个人待办”,这两个状态一定不能混着存。很多初级实现把“当前节点”直接放在 task 上,一旦遇到会签这样的多任务节点,并发更新就会把状态盖掉,具体坑在后面避坑章节展开。

关键认知是:实例和任务是一对多,而不是一对一。设计时把这两个结构分开,后面的会签、加签、转交才有地方放。

2.2 JS 生态的审批流三条路线:自研、状态机库、BPMN 画布的取舍

聊完模型,下一个问题必然是“要不要上现成的东西”。我把常见做法归纳成三条路线,分别说清楚边界在哪里。

路线一:BPMN 标准加画布。BPMN 的好处是模型标准化、可视化能力强,业务同事能直接拖拽流程图。但要注意,JS 生态里成熟的可视化库主要解决“画图”和“导出 XML”,不等于帮你“跑流程”。引擎层仍然要自己写,或者配合别的后端引擎来执行。也就是说,画布只解决建模和展示,流转逻辑还得自己做,引入成本不低。

路线二:现成的状态机库。状态机把“节点”映射成状态,把“同意、驳回、转交”映射成事件,对直线型审批流很够用。但审批流里真正麻烦的是条件分支、动态审批人、会签汇总这些状态机不擅长的结构。一旦出现“A 审批通过后,如果金额大于五千还要走财务复核,否则直接结束”,状态机的转移表会变得很别扭,最后仍然要写大量胶水代码。

路线三:自研轻量引擎。用 JSON 描述流程定义,自己维护一个“当前节点 + 历史事件”的核心数据结构,流转逻辑控制在几百行内。这个方案两三天内能跑通,后续扩展条件判断、会签、超时催办都容易。三条路的取舍可以放在一张表里看:

方案启动成本可维护性最合适场景
BPMN + 画布高,引擎得自己找流程可视化强,但链路长非技术人员要自己改流程
状态机库低简单流程清晰,复杂流程胶水多节点少、无分支的直线审批
自研轻量引擎中,2-3 天全在自己手里,迭代快内部后台、OA 类产品

我的结论比较直接:业务后台的审批流,选路线三。什么时候要换重型玩法?当出现“流程多版本并行、跨系统流程集成、多个部门都要求可视化建模”时,说明你需要的已经不是审批流而是独立的流程平台,那再上升到路线一也不迟。但在那之前,自研轻量引擎的投入产出比最高。

2.3 审批人不能写死在定义里:表达式解析与动态指派

审批流里最容易忽略的是“人”的部分。很多第一次做的人把流程定义写成{ nodeId: 'manager', approver: 'u_1024' },直接把审批人写死成张三。这么做当场跑通,但上线三天就会出问题:张三离职了、张三调岗了,或者张三的审批权限只针对特定分支才有效。

常见做法是把审批人写成“表达式”,到节点真正激活时再解析。我一般会支持这三种表达式类型:发起人的直属上级、某个角色的任意成员、以及写死指定人。写死指定人只用于特殊固定节点,比如“法务复核人固定是某某”,其余场景尽量用角色或组织关系表达。

关键点是解析时机。我见过两种实现:一种在发起流程时就把全部节点审批人解析好存起来,一种在节点到达时动态解析。我的习惯是“到达节点时解析,同时把解析结果记录到历史里”。原因很直接:审批链走到半路,主管可能换了;等节点到的时候再查一次组织架构,拿到的才是当前应该审批的人。历史里记录解析结果,是为了将来能追溯“当时到底是谁被指派了”。

这个设计顺带解决了一个问题:加签与转交。加签等于临时在节点上插入一条审批人表达式;转交等于把 task 的 assignee 改成另一个人。两者都不需要改流程定义,只需要改当前任务或当前节点,核心引擎完全不用动。

3. 用纯 JS 写最小审批引擎:JSON 流程定义与三步流转代码

这一章进入代码。我们要做一个能跑的最小引擎,目标不是生产级完整实现,而是把核心逻辑说透:一个流程定义怎么写,实例如何创建,同意和驳回如何流转。把这段代码跑通,你就掌握了审批流引擎的主干。

3.1 一份请假审批流的 JSON 定义:节点、边与条件字段

先看流程定义。以请假为例:发起后先由直属主管审批;请假 1-2 天主管同意就结束;请假 3 天及以上还要过部门经理。用 JSON 表达如下:

const leaveFlow = { id: 'leave_flow_v1', nodes: [ { id: 'start', type: 'start', name: '发起' }, { id: 'leader', type: 'approve', name: '直属主管审批', approver: { type: 'reporter.leader' } }, { id: 'manager',type: 'approve', name: '部门经理审批', approver: { type: 'role', role: 'manager' } }, { id: 'end', type: 'end', name: '结束' } ], edges: [ { from: 'start', to: 'leader' }, { from: 'leader', to: 'manager', condition: { field: 'days', op: 'gte', value: 3 } }, { from: 'leader', to: 'end', condition: { field: 'days', op: 'lt', value: 3 } }, { from: 'manager', to: 'end' } ] };

字段说明:nodes 里只有四类,start 起点、approve 审批节点、end 终点。真实项目还会加 cc 抄送、condition 条件节点,但最小引擎里条件直接挂在边上就够。edges 里每条边有 from、to,可选的 condition 是一个{ field, op, value }结构,表示“表单某个字段满足条件才走这条边”。

注意这里的条件我没有用脚本表达式,而是结构化数据。这样做的原因很现实:一是避免在服务端执行字符串表达式带来的安全风险,二是排查问题时可以直接看数据,不用猜一段字符串到底怎么解析。要支持新条件时,在引擎里扩展一个匹配分支即可。

3.2 引擎核心代码:创建实例、同意、驳回与下一节点匹配

下面是最小可用引擎。它负责三件事:发起时生成实例和首批任务;同意时找下一节点并生成新任务;驳回时把整个实例置为 rejected。代码刻意保持在一个文件里,方便你复制出来跑。

// engine.js —— 最小审批引擎(演示用) class ProcessEngine { constructor({ findUser, listRoleUsers }) { this.findUser = findUser; // 按 id 查用户资料 this.listRoleUsers = listRoleUsers; // 按角色查用户列表 } // 发起流程:生成实例和第一个节点的任务 createInstance(flowDef, initiator, formData) { const startNode = flowDef.nodes.find(n => n.type === 'start'); const instance = { id: genId(), flowDefId: flowDef.id, status: 'running', currentNodeId: startNode.id, initiator, formData, version: 1, history: [{ nodeId: startNode.id, action: 'submit', at: Date.now(), by: initiator }] }; const tasks = this._activateNode(instance, flowDef); return { instance, tasks }; } // 处理审批决策:approve 或 reject async handleDecision(instance, flowDef, task, decision, operator, comment) { if (task.status !== 'pending') { throw new Error('TASK_ALREADY_DONE'); // 幂等保护 } task.status = 'done'; instance.history.push({ nodeId: task.nodeId, action: decision, at: Date.now(), by: operator, comment }); if (decision === 'reject') { instance.status = 'rejected'; instance.currentNodeId = null; return { instance, tasks: [] }; } const nextNode = this._nextNode(flowDef, instance, task.nodeId); if (!nextNode || nextNode.type === 'end') { instance.status = 'completed'; instance.currentNodeId = nextNode ? nextNode.id : null; return { instance, tasks: [] }; } instance.currentNodeId = nextNode.id; const tasks = this._activateNode(instance, flowDef); return { instance, tasks }; } // 找下一节点:按条件匹配边,没有条件匹配就走第一条边 _nextNode(flowDef, instance, fromNodeId) { const edges = flowDef.edges.filter(e => e.from === fromNodeId); if (edges.length === 0) return null; const hit = edges.find(e => this._match(instance.formData, e.condition)); const edge = hit || edges[0]; return flowDef.nodes.find(n => n.id === edge.to); } // 条件匹配:只支持预设操作符,禁止 eval / new Function _match(data, condition) { if (!condition) return true; const actual = data[condition.field]; switch (condition.op) { case 'gte': return actual >= condition.value; case 'lt': return actual < condition.value; case 'eq': return actual === condition.value; default: return false; } } // 激活当前节点:解析审批人,生成待办任务 _activateNode(instance, flowDef) { const node = flowDef.nodes.find(n => n.id === instance.currentNodeId); const assignees = this._resolveApprover(node, instance); return assignees.map(userId => ({ id: genId(), instanceId: instance.id, nodeId: node.id, assignee: userId, status: 'pending' })); } // 审批人解析:把表达式翻译成具体用户 id 列表 _resolveApprover(node, instance) { const expr = node.approver; if (expr.type === 'fixed') return [expr.userId]; if (expr.type === 'reporter.leader') return [this.findUser(instance.initiator).leaderId]; if (expr.type === 'role') return this.listRoleUsers(expr.role).map(u => u.id); throw new Error('UNKNOWN_APPROVER_EXPR'); } }

代码逻辑说明:createInstance 先把实例指向 start 节点,再由 _activateNode 解析审批人生成任务。注意实例和任务是分开返回的,前端待办列表只要展示 task,详情页展示 instance。handleDecision 是唯一的状态入口,同意时先找下一个节点,驳回时直接终止实例。函数里对已完成任务做了幂等保护,这是为“用户双击提交”准备的。

_nextNode 的匹配顺序值得细看:先按条件过滤,匹配到才走;没有条件或都不匹配时走第一条边。这个“兜底边”设计让流程定义可以写得精简,比如“大于等于 3 走经理,否则走结束”,两条边里总会有一条兜底。_match 只实现了三个操作符,新增操作符只需要加一个 case 分支。

参数说明:findUser(initiator) 返回用户对象,至少包含 leaderId 字段,表示直属上级;listRoleUsers(role) 返回该角色下的用户对象数组。这两个函数是你接入组织架构数据的桥,具体实现因系统而异,引擎不关心。engine 的 handleDecision 我标了 async,但内部没有 await,保留它是为了方便你将来在这里落库或发事件。

3.3 审批人解析的三种表达式与解析时机

_resolveApprover 里那三行是审批流的胜负手。fixed 简单直接,只用于“法务复核人固定是某某”这类写死场景;role 适合“部门经理”这种按角色找人;reporter.leader 是最常见的“发起人的主管”。三种表达式组合起来,基本能覆盖内部系统 90% 的审批人规则。

有一个关键点必须强调:解析发生在节点激活时,而不是发起流程时。有的实现为了省事,createInstance 时就把整条链路的人全部查询好存在表里,一旦组织架构变动,流程只能按旧人走,非常被动。做到节点到达时解析,才能真正贴合“谁现在是主管”的预期。

会签也在这里扩展。当前代码里 _activateNode 把 assignees 数组全部生成任务,但没处理“要所有人都同意才能往下走”的汇总逻辑。生产实现里任务需要加一个 strategy 字段:strategy 为 any 时一票通过即推进,为 all 时所有任务完成才推进。这块逻辑不复杂,但却是审批流从演示走向可用的分水岭。

3.4 校验流程定义合法性:用拓扑排序提前发现死循环

引擎能跑不等于定义合法。最容易写错的是流程定义成环:A 审批通过去 B,B 驳回去 C,C 又回 A。运行时就会无限循环。我的习惯是在流程定义保存时先做拓扑排序检查,而不是等运行期炸了再排查。

// 有向图是否有环:拓扑排序,能遍历全部节点则无环 function hasCycle(flowDef) { const graph = {}; const inDegree = {}; flowDef.nodes.forEach(n => { graph[n.id] = []; inDegree[n.id] = 0; }); flowDef.edges.forEach(e => { graph[e.from].push(e.to); inDegree[e.to]++; }); const queue = Object.keys(inDegree).filter(id => inDegree[id] === 0); let visited = 0; while (queue.length) { const id = queue.shift(); visited++; for (const next of graph[id]) { inDegree[next]--; if (inDegree[next] === 0) queue.push(next); } } return visited !== flowDef.nodes.length; }

逻辑说明:拓扑排序把所有入度为 0 的节点不断弹出,如果最后弹出的节点数不等于总节点数,说明图里有环。这比运行期检测简单可靠,保存流程定义时提前拦一层,避免把坏结构发布上去。

注意:有些场景确实需要“驳回回上一节点”,那是通过事件处理实现的回退,不是流程定义成环。定义里的边仍然要保持有向无环,回退逻辑单独写在 handleDecision 的 reject 分支里。

4. 前端接入审批流:按钮权限、审批历史与轮询的常见写法

后端引擎跑通了,前端就该进场。前端要做的其实很有限:渲染任务、渲染按钮、渲染历史。真正复杂的流转判断留在后端,前端一旦开始自作主张判断权限,后续会签、转交、驳回这些场景会让你改到怀疑人生。

4.1 后端下发的 actions 决定按钮,前端不做权限判断

最常见的前端翻车点是按钮权限写死在前端。比如代码里写死“status 是 running 就显示同意按钮”,一旦出现会签节点、转交场景,同一个状态对不同人可见的按钮完全不同,写死必然出错。

我的做法是后端在返回任务详情时,直接下发 actions 数组:

// GET /api/tasks/:id 的返回片段 { task: { id: 'task_001', nodeId: 'manager', status: 'pending' }, actions: [ { id: 'approve', label: '同意', theme: 'primary' }, { id: 'reject', label: '驳回', theme: 'danger' }, { id: 'transfer', label: '转交', theme: 'default' } ] }

前端只需要把 actions 渲染成按钮。谁可以批、可以批哪些动作,由后端根据当前用户、任务状态、节点类型、实例状态综合判断。前端不知道规则,也不需要知道。按钮与状态的对应可以整理成一张表,方便前后端对齐:

实例状态任务状态当前用户身份后端下发的 actions
runningpending当前审批人同意、驳回、转交
runningpending非当前审批人只看不可操作
runningdone历史审批人无操作按钮
completed / rejected-所有人无操作按钮

这张表的价值在于,后端可以直接按它实现 actions 生成逻辑,前端只负责渲染。以后如果加了“加签”动作,只需要后端在 actions 里多返回一项,前端零改动。

4.2 审批历史时间线:一次取回,前端渲染快照

审批历史是用户判断“这单到底卡在哪”的核心页面。我的习惯是后端把历史一次性返回,前端只做渲染。历史事件的结构设计成时间线友好的格式:

// GET /api/instances/:id/history 的返回片段 [ { action: 'submit', at: 1700000000000, by: { id: 'u_1', name: 'A同学' }, from: 'start', to: 'leader', comment: '提交 5 天年假申请', snapshot: { days: 5 } }, { action: 'approve', at: 1700003600000, by: { id: 'u_2', name: '直属主管' }, from: 'leader', to: 'manager', comment: '同意,交部门经理', snapshot: { days: 5 } }, { action: 'reject', at: 1700007200000, by: { id: 'u_3', name: '部门经理' }, from: 'manager', to: null, comment: '请补充请假事由', snapshot: { days: 5 } } ]

渲染成时间线时,前端拿到每条事件的 from、to、by、comment 就能画出一串节点变化。这里要特别重视 snapshot 字段:它是该事件发生时表单数据的快照。驳回场景中,用户回看历史时应该看到“当时提交的数据是什么”,而不是当前表单,否则发起人把数据改了之后历史就说不清了。

注意:snapshot 一定要做深拷贝。浅拷贝在嵌套对象下会引到同一内存地址,后续表单数据一变,历史记录里的快照照样被改,等于没存。

4.3 待办实时感知:轮询的间隔与生命周期管理

待办列表天然是“时不时有新任务进来”的场景。对接方式上,内部系统普遍用轮询,实时协作平台才上长连接。我的经验值是:内部后台 30 秒一轮,低代码平台 10 秒一轮,再短就没必要了,反而增加数据库压力。

轮询实现要处理两个细节:一是用 setTimeout 而不是 setInterval,避免上一个请求还没回来下一个又发出去;二是页面隐藏时停止轮询,不空耗请求。

function pollTodoList(intervalMs = 30000) { let timer = null; async function tick() { try { const list = await fetch('/api/tasks/todo').then(r => r.json()); renderTodoList(list); } catch (e) { // 单次失败不中断,下次继续 console.error('poll todo error', e); } finally { if (!document.hidden) { timer = setTimeout(tick, intervalMs); } } } tick(); document.addEventListener('visibilitychange', () => { if (!document.hidden && !timer) timer = setTimeout(tick, intervalMs); }); }

逻辑说明:每次请求结束后再排下一次定时器,保证了请求不会重叠;document.hidden 为 true 时不再续排定时器,页面切回来后再恢复。细节上,审批详情页的轮询要更克制:只有实例还在 running 且页面可见时才轮询,一旦状态变成 completed 或 rejected,立刻停止。前端用 status 判断即可,不需要额外接口。

5. 避坑指南:审批流并发覆盖、驳回快照、审批人过期的排查方法与修复

这一章是血泪经验。审批流跑通很容易,跑稳很难。下面五个问题是我在不同项目里反复见过的,每一个都值得在上线前提前预防。它们的共同规律是:审批流的稳定性不靠前端约束,而靠后端的幂等、乐观锁和状态机校验。

5.1 并发审批把实例状态覆盖了

现象:一个会签节点同时有三个人审批,两个人同时点了同意。结果实例状态被后提交的请求覆盖,本来应该等三个人都通过,实际只用了第二次提交的结果就流转到下个节点。

原因:实例表更新时没有版本控制。后端常见的写法是“取出实例、改字段、保存”,两次请求同时读出同一个 version,后保存的覆盖先保存的提交。会签节点的汇总逻辑也容易在这里出错:每次任务完成都直接改实例状态,而不是先统计已完成任务数。

解决:更新实例时带乐观锁,条件里带上版本号。

UPDATE flow_instance SET status = ?, currentNodeId = ?, version = version + 1 WHERE id = ? AND version = ?

affected 行数为 0 时说明版本对不上,说明并发发生了,后端要重新读实例再判断。另外,会签节点的汇总逻辑也要放在一个事务里:每次任务完成时把结果写进 voteSummary 字段,只有达到通过条件时才推进实例。

5.2 驳回后审批人看不见当初填了什么

现象:报销单被驳回,审批人只看到一句“请补充材料”,但页面里展示的已经是发起人改过的数据,没人知道驳回那一刻原始数据长什么样。

原因:历史事件只记录了“谁在什么时候做了什么”,没有记录当时的表单快照。页面展示的是当前 formData,数据一变历史就失真。

解决:节点每次完成时,把当前 formData 的深拷贝放进历史事件的 snapshot 字段。审批历史页面只展示 snapshot 里的数据,不读最新表单。这里再次强调深拷贝:嵌套对象下浅拷贝会引到同一内存地址,历史数据照样被改。你可以用JSON.parse(JSON.stringify(formData))这种暴力方式,也可以引入结构化克隆,看项目里已有的工具链。

5.3 审批人解析结果过期

现象:流程发起时选的审批人是甲,三天后流程才流转到审批节点,甲已离职,任务卡住没有负责人。

原因:发起流程时就把后续所有节点的审批人解析好并写死在任务表里。组织架构变动后,任务仍指向旧人,没人接手。

解决:改为节点激活时实时解析审批人表达式,任务生成时把解析结果记录到历史。如果解析结果为空,立刻给管理员发告警,不要让任务沉默地挂在系统里。这也是前面引擎代码里把 _activateNode 放到节点到达时调用的原因。你可以理解为:审批人是运行时概念,不是建模时概念。

5.4 条件分支用 eval 执行字符串,留下安全风险

现象:流程定义为了支持“金额大于 1000 且部门不是财务部”这类组合条件,把条件写成表达式字符串,运行时用 new Function 执行。结果表达式里被塞进恶意代码,服务器执行了预期外的逻辑。

原因:图省事,用字符串拼逻辑。流程定义一旦可以被非技术同事编辑,这个口子就会出问题。

解决:条件只用结构化数据{ field, op, value },组合条件用数组嵌套表达。数组内多个条件默认全部满足才匹配,也就是 AND 关系:

// 金额大于 1000 且部门不是 finance [ { op: 'gte', field: 'amount', value: 1000 }, { op: 'neq', field: 'dept', value: 'finance' } ]

引擎侧做一个匹配器按白名单操作符逐条执行,永远不把字符串当代码运行。要支持 or 关系时,再加一个 group 层,不要把逻辑拼成字符串。

5.5 流程都结束了,待办里还能点同意

现象:实例状态已经是 completed,但某个历史任务在待办列表里还能打开并提交,提交后也没有报错,看起来很“正常”,其实状态已经错乱。

原因:接口层只校验了“任务存在”,没校验“实例还在 running”。前端隐藏按钮只是界面手段,接口没拦住照样能打。

解决:任务决策接口做双重校验:任务状态必须为 pending,实例状态必须为 running,两个条件不满足直接返回 409。同时依赖 5.1 的乐观锁兜底,同一任务重复提交时第二次直接走“任务已完成”分支返回原结果,保证幂等。接口的失败信息要带回可读的提示,前端拿到 409 后刷新页面并提示“当前流程状态已变化”,不要让用户觉得是自己操作失败。

6. 进阶技巧:给审批引擎加超时催办与超时自动处理,附扫描代码

审批流上线三个月后,最常被提的需求就是“能不能到时间自动提醒,或者自动通过”。这一章给出一个通用做法,顺手还能规避掉一个常见设计错误。

6.1 给任务加 timeout_at,把“等待”变成一种定时任务

不要在审批节点里写轮询,而是每个任务生成时计算一个 timeoutAt,比如“从生成时刻起 48 小时”,存在任务表里。系统只需要一个后台定时任务,周期性扫描超时的任务。超时策略放在流程定义的节点上,由配置决定,不用改引擎。

6.2 扫描超时任务的代码与参数说明

// timeoutScheduler.js async function scanTimeoutTasks(now) { const overdueTasks = await taskRepo.findOverdue(now); // timeout_at <= now 且 status=pending for (const task of overdueTasks) { const instance = await instanceRepo.get(task.instanceId); const node = flowDefRepo.get(instance.flowDefId).nodes.find(n => n.id === task.nodeId); if (node.timeoutPolicy === 'auto_approve') { // 超时自动通过:仍然走引擎统一入口,保证历史与状态一致 await engine.handleDecision(instance, flowDef, task, 'approve', 'system', '超时自动同意'); } else if (node.timeoutPolicy === 'auto_transfer') { // 超时转交给上级,而不是直接放行 await taskRepo.transfer(task.id, findLeader(task.assignee)); await notifyService.send(task.assignee, '你的待办已超时,已转交上级'); } else { // 默认策略:催办 await notifyService.send(task.assignee, '你有待办即将超时,请尽快处理'); } } }

参数说明:timeoutPolicy 配置在流程定义的节点上,有三个可选值:auto_approve、auto_transfer、空(仅催办)。超时自动通过也调用 handleDecision,而不是直接改状态,这样才能写入历史事件、触发后续节点。扫描任务每五分钟跑一次即可,不需要精确到秒,成本很低。

6.3 什么业务可以自动通过,什么不行

自动通过是把双刃剑。请假、会议室预订这类“发起人自己承担风险”的场景,超时自动通过能明显提升流转效率;报销、付款、合同这类涉及资金和法务的场景,绝对不能用自动通过。我曾在做某个内部系统时给报销节点配了 auto_approve,上线第一周就有人抱怨“没人审批钱就付出去了”,后来改成超时自动转交上级,把决定权保留在人这里。

从那以后我给自己定了一条规矩:凡是涉及钱和承诺的节点,只催办或转交,绝不自动通过;凡是低风险内部流程,才考虑自动放行。超时扫描本身就是一种压力传导,很多时候催办比自动通过更有效。希望这次的引擎设计与避坑记录能帮到你少走一段弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进