简介:一份围绕 JavaScript 工作流与审批流主题的资料包,面向需要设计或维护 Web 端审批系统的初中级开发者,重点解决业务中任务流转、多人审批、状态管理等常见问题,适合用于快速理解审批流的基本实现方式。压缩包共 49 个文件、约 69KB,体积虽小但结构完整:8 个 JS 脚本负责流程逻辑,8 个 HTML 页面提供审批界面,6 个 XML 文件用于流程定义,2 个 ASPX 页面与 2 个 C# 文件展示前后端配合,另有 CSS 样式表、GIF 操作演示、数据库文件等资源,可直接看到从界面到数据存储的完整链路。已有 1285 人学习下载。资料内容涉及工作流基本概念、状态机驱动与 BPMN 建模思路、任务分配与角色权限控制、用户审批界面交互、状态持久化、异常回退及审计日志等关键环节。包内附有可直接参考的页面和脚本,并覆盖提交流程、跨步骤回退、操作记录追踪等常见场景的处理方式,对理解审批流设计模式和后续二次开发均有参考价值。
1. JS 工作流不是状态机:审批流正确的三层拆法
一提起 JS 工作流,很多后台项目第一反应是给业务表加一个 status 字段,再用 if-else 处理「提交、通过、驳回」三个动作。这个思路在上手阶段没问题,但真到了会签、条件分支、多级审批、驳回后再提交这些需求时,if-else 会迅速膨胀到没人敢改的程度,线上问题也没后悔药吃。
审批流的本质是「流程定义 + 流程实例 + 任务」三层:流程定义描述节点和连线,流程实例记录一次申请走到哪,任务是具体某个人要处理的待办。这三层在 JS 生态里可以用 bpmn-js 画流程图、用 Node 写一个小引擎、用三张表把状态落库。
这篇文章按这条路径把拆法、建模、引擎、避坑一次讲完,适合正在把请假、报销、工单这类业务改造成可配置审批流程的前端或全栈工程师。
2. 把审批流拆成数据:流程定义 JSON 与三张表的建模
很多人理解的审批流是状态机,其实状态机描述的是单个流程实例的状态转换,而工作流同时要管理「多个人的多个任务」,实例状态和任务状态必须分开存。下面给出我常用的数据模型,它是后面设计器、引擎、避坑三章的公共底座,先把这个底座立住,后面写代码才不会反复返工。
2.1 流程定义:为什么用 JSON 而不是把逻辑写进代码
流程定义是一段描述「节点、连线、条件、审批人」的配置数据。常见的做法是存一份 JSON,引擎启动时按 version 加载。节点类型最少要有 start、approve、condition、end 四种,approve 节点负责审批人分配和驳回目标,condition 节点负责分支路由。
{ "processKey": "leave_v1", "name": "请假审批", "nodes": [ { "id": "start", "type": "start", "next": ["leader_approve"] }, { "id": "leader_approve", "type": "approve", "name": "直接上级审批", "assignee": { "type": "role", "value": "leader_of_applicant" }, "mode": "single", "rejectTo": "start", "next": ["condition_days"] }, { "id": "condition_days", "type": "condition", "conditions": [ { "expression": { "field": "days", "op": ">", "value": 3 }, "next": ["hr_approve"] } ], "defaultNext": "end" }, { "id": "hr_approve", "type": "approve", "name": "HR 审批", "assignee": { "type": "role", "value": "hr" }, "mode": "single", "rejectTo": "leader_approve", "next": ["end"] }, { "id": "end", "type": "end" } ] }这段 JSON 描述了一条完整链路:提交后先进「直接上级审批」,通过后进入条件网关,请假天数大于 3 就去 HR 审批,否则直接结束。关键参数在三处:mnode 的mode决定审批方式是 single(一人通过)还是 all(多人会签);condition 节点的expression只支持 field、op、value 三段式,方便引擎安全求值;rejectTo明确驳回后回到哪个节点,不配置默认回 start。
我一般把流程定义存进数据库表而不是代码常量,原因很简单:改审批链路不能等发版窗口。节点里现在还不需要配并行网关,并行场景我通过next: ["a", "b"]数组来表达,引擎按数组长度拆任务。条件分支和并行拆分尽量分开设计,混在一起调试时很难定位问题。
2.2 三张基础表:实例表、任务表、审批记录表
运行时的数据我固定用三张表:wf_instance 记录一次申请的整体状态,wf_task 记录每个待办任务,wf_record 记录每一次操作痕迹。这三张表是 MySQL 方言,用别的数据库改一下类型即可。
CREATE TABLE wf_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_key VARCHAR(64) NOT NULL, def_version INT NOT NULL, biz_type VARCHAR(32), biz_key VARCHAR(64), status VARCHAR(16) NOT NULL DEFAULT 'running', current_node VARCHAR(64), applicant VARCHAR(64) NOT NULL, form_data JSON, create_time DATETIME NOT NULL, end_time DATETIME ); CREATE TABLE wf_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_id VARCHAR(64) NOT NULL, node_name VARCHAR(128), assignee VARCHAR(64), role_code VARCHAR(64), mode VARCHAR(16) NOT NULL DEFAULT 'single', vote_yes INT NOT NULL DEFAULT 0, vote_target INT NOT NULL DEFAULT 1, status VARCHAR(16) NOT NULL DEFAULT 'todo', create_time DATETIME NOT NULL, end_time DATETIME ); CREATE TABLE wf_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, task_id BIGINT, node_id VARCHAR(64), operator VARCHAR(64) NOT NULL, action VARCHAR(16) NOT NULL, comment VARCHAR(512), create_time DATETIME NOT NULL );字段设计里需要注意三个点。第一,wf_task 必须单独存在,因为一个审批节点可以拆出多条待办,比如会签节点要给三个人各生成一条任务,如果把任务状态塞进实例表,并行和会签就没法表达。第二,wf_record 只能 insert 不能 update,每次同意、驳回都追加一行,它是事后追溯的唯一证据,vote_yes这种数字字段只能当冗余加速用,不能当真相。第三,业务表不要存流程状态,业务表里最多存一个 biz_key 和 wf_instance_id,审批走到终态后由引擎回调业务模块写业务状态。
实例表里def_version是快照版本号,创建实例时把流程定义的版本写死,后续流程定义发布新版本也不会影响正在跑的申请。表之间不加外键,数据量大之后分库不方便,一致性交给引擎的事务去保证。引擎的职责很收敛:启动实例时生成首任务,审批操作时推进节点、生成或关闭任务,这就是下一篇要讲的内容。
3. 用 bpmn-js 画流程图:建模、保存与节点审批人配置
流程定义在库里是 JSON,但让业务方直接编辑 JSON 不现实。常见做法是用 bpmn-js 做一个可视化设计器,业务方拖节点、连线、配置审批人,保存时把「图形 XML」和「语义配置 JSON」一起提交。bpmn-js 是浏览器里渲染和编辑 BPMN 2.0 图的库,按引用数和使用广度来说,是 JS 工作流方向最稳定的选择之一。
3.1 最小可用的流程设计器:初始化、加载与保存
import BpmnModeler from 'bpmn-js/lib/Modeler'; import 'bpmn-js/dist/assets/diagram-js.css'; import 'bpmn-js/dist/assets/bpmn-js.css'; const modeler = new BpmnModeler({ container: '#process-design-canvas', keyboard: { bindTo: document }, }); const emptyXml = `<?xml version="1.0" encoding="UTF-8"?> <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn"> <bpmn:process id="Process_1" isExecutable="true" /> <bpmndi:BPMNDiagram id="BPMNDiagram_1"> <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_1" /> </bpmndi:BPMNDiagram> </bpmn:definitions>`; async function loadDiagram(xml = emptyXml) { await modeler.importXML(xml); const canvas = modeler.get('canvas'); canvas.zoom('fit-viewport'); } async function saveDiagram() { const { xml } = await modeler.saveXML({ format: true }); return xml; }new BpmnModeler 时 container 指向画布 DOM,keyboard 绑定键盘事件用于删除节点,必须传入 document,否则快捷键失效。importXML 返回 Promise,等它完成后再操作 canvas;zoom('fit-viewport') 把整张图缩放到视口内,避免流程较大时看不到边角。保存时 saveXML 的 format 参数表示输出格式化缩进的 XML,便于比对版本差异。
空模板字符串是初始化新流程图的最小 XML,注意 process 元素上的 isExecutable 要设为 true,bpmn-js 默认可视,但导入到其他 BPMN 引擎时这个标记会影响是否被当作可执行流程。加载已有流程时把库里存的 XML 传给 loadDiagram 即可。
3.2 给节点配审批人:XML 与配置 JSON 双轨方案
给 BPMN 节点附加自定义属性有标准做法,但初版我建议用双轨方案:XML 只管布局,审批人、驳回目标、条件表达式等语义全部放在一个 config JSON 里,保存时两个都提交。原因是 BPMN 的 XML schema 比较严格,自定义属性如果不做 moddle 扩展声明,再导入导出时会被静默丢掉,调试起来像黑匣子,够折腾一阵。
const registry = modeler.get('elementRegistry'); const shapeList = registry.getAll().filter((el) => ['bpmn:StartEvent', 'bpmn:UserTask', 'bpmn:EndEvent', 'bpmn:ExclusiveGateway'] .includes(el.type) ); const nodeBrief = shapeList.map((el) => ({ id: el.id, type: el.type, name: el.businessObject.name || '', })); modeler.on('element.dblclick', (event) => { const element = event.element; if (!element || !element.id) return; // 双击节点打开右侧配置面板, 编辑审批人/mode/rejectTo/条件 openNodeConfigPanel(element.id, nodeBrief); });拿到 elementRegistry 后,通过 type 过滤出真正需要配置的节点,StartEvent、UserTask、EndEvent、ExclusiveGateway 对应 start、approve、end、condition 四类。每个节点用 el.id 作为 config JSON 的 key,业务方在面板里改审批人是「直接上级」还是「HR」,最后统一存进 nodeConfigs。
async function saveProcess() { const xml = await saveDiagram(); const payload = { xml, config: nodeConfigs, // [{ id, name, assignee, mode, rejectTo, conditions }] }; await fetch('/api/process/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); }后端收到 payload 后,XML 存一份用于展示和二次编辑,config JSON 转成第 2 章那套流程定义结构存库。引擎运行时只读 config,不看 XML。这个方案的优点是结构清晰,config 里可以放心放表单 schema、超时配置、会签人数这些业务字段,不会被 BPMN 解析器拦截。等你对 moddle 扩展足够熟悉,再把语义合并进 XML 也不迟。
3.3 bpmn-js 导入导出的三个坑:重复导入、非法 ID 与属性丢失
第一个坑是重复 importXML。一个 BpmnModeler 实例只能初始化一次,第二次 importXML 会直接报错。要重新加载另一张流程,先调 modeler.destroy() 再 new 一个新实例,不能图省事复用。
function resetModeler(container) { if (modeler) modeler.destroy(); modeler = new BpmnModeler({ container }); }第二个坑是节点 ID 用中文或空格。BPMN 规范对 id 的限制偏保守,bpmn-js 在不同浏览器版本里对中文 ID 的兼容性有差异,偶尔出现连线丢失、导出后再导入找不到元素的问题,某种意义上算是玄学。我统一用task_approve_leader、gateway_days这种英文短横线命名,省心。
第三个坑是删除节点后 config 残留。删除一个审批节点后,nodeConfigs 里还留着旧记录,保存时如果不清理,引擎加载流程定义会报「找不到节点」。保存前按当前 elementRegistry 过滤一遍,把已经不存在的节点配置剔除:
const aliveIds = new Set(registry.getAll().map((el) => el.id)); nodeConfigs = nodeConfigs.filter((c) => aliveIds.has(c.id));这三个坑都发生在「设计期」,但它们会直接污染运行期数据,尤其是第三个。设计器保存的流程定义,引擎加载前要做一次 schema 校验,至少检查 next 指向的节点是否都存在,否则审批半路会翻车。
4. 用 Node 写审批引擎:审批、驳回、会签的推进逻辑
设计器产出流程定义,运行时需要一个轻量引擎。轻量的意思是不要一上来就引重服务,用 Node 加一个事务函数,把「审批通过、驳回、会签计数」收敛成一个入口。所有审批动作都走同一个函数,状态就不会在各处被改乱。
4.1 approveTask:一次审批的事务边界
const db = require('./db'); // mysql2/promise 连接池 async function approveTask({ taskId, userId, action, comment = '', formData = {} }) { const conn = await db.getConnection(); try { await conn.beginTransaction(); // 1. 行锁定位任务, 锁住后再查状态, 防止并发重复处理 const [rows] = await conn.query( 'SELECT * FROM wf_task WHERE id = ? FOR UPDATE', [taskId] ); const task = rows[0]; if (!task || task.status !== 'todo') { throw new Error('任务不存在或已被处理'); } // 2. 权限校验: 指定人或指定角色 if (task.assignee && task.assignee !== userId) { throw new Error('当前任务不属于你'); } if (task.role_code && !(await userHasRole(conn, userId, task.role_code))) { throw new Error('当前角色无权操作'); } // 3. 写审批记录, 只追加不更新 await conn.query( `INSERT INTO wf_record (instance_id, task_id, node_id, operator, action, comment) VALUES (?, ?, ?, ?, ?, ?)`, [task.instance_id, task.id, task.node_id, userId, action, comment] ); // 4. 否决直接走驳回逻辑 if (action === 'reject') { await rejectFlow(conn, task); await conn.commit(); return { status: 'rejected', message: '流程已驳回' }; } // 5. 会签计数: 达到 vote_target 才继续, 否则停留在当前节点 if (task.mode === 'all') { await conn.query( 'UPDATE wf_task SET vote_yes = vote_yes + 1 WHERE id = ?', [task.id] ); const [cntRows] = await conn.query( 'SELECT vote_yes, vote_target FROM wf_task WHERE id = ?', [task.id] ); if (cntRows[0].vote_yes < cntRows[0].vote_target) { await conn.commit(); return { status: 'waiting', message: '已表决, 等待其他审批人' }; } // 达标后继续往下走, 进入当前任务的完结逻辑 } // 6. 完结当前任务 await conn.query( "UPDATE wf_task SET status = 'done', end_time = NOW() WHERE id = ?", [task.id] ); // 7. 解析下一批节点, 生成新任务或结束实例 const definition = await loadDefinition(task.instance_id); const nextNodes = await resolveNextNodes(definition, task.node_id, formData); if (nextNodes.length === 0) { await conn.query( "UPDATE wf_instance SET status = 'finished', current_node = 'end', end_time = NOW() WHERE id = ?", [task.instance_id] ); } else { for (const node of nextNodes) { await createTask(conn, task.instance_id, node); } await conn.query( 'UPDATE wf_instance SET current_node = ? WHERE id = ?', [task.node_id, task.instance_id] ); } await conn.commit(); return { status: 'approved', message: '审批通过' }; } catch (err) { await conn.rollback(); throw err; } }这个函数是整个引擎的核心,事务边界从「锁任务」开始,到「生成新任务」结束。中间任何一步失败都回滚,不会出现审批记录写了但任务没推进的脏状态。逻辑上特别注意三点:第一,SELECT ... FOR UPDATE 必须在事务内先执行,锁住任务行之后再做状态判断,两个审批请求同时到达时,第二个会等锁释放后看到 status 变成 done,直接被挡掉;第二,审批记录比状态更新先写入,保证任何时候记录都不缺失;第三,会签计数在单任务行上累加,而不是拆成多个子任务,这样「一票否决」和「达到目标才通过」都好控制。
action 参数只接受 approve 和 reject 两值。mode 在任务创建时确定,single 表示一个人通过即通过,all 表示要 vote_target 票才放行。formData 是当前审批人提交的表单运数据,条件分支求值要靠它。
4.2 节点跳转:顺序、条件分支与并行拆解
async function resolveNextNodes(definition, currentNodeId, formData) { const node = definition.nodes.find((n) => n.id === currentNodeId); if (!node) return []; // 并行: 一个节点指向多个节点, 各自生成独立任务 if (Array.isArray(node.next) && node.next.length > 1) { return node.next .map((id) => definition.nodes.find((n) => n.id === id)) .filter(Boolean); } // 条件网关: 走第一个命中的分支 if (node.type === 'condition') { for (const cond of node.conditions || []) { if (evaluateExpression(cond.expression, formData)) { return [definition.nodes.find((n) => n.id === cond.next)].filter(Boolean); } } if (node.defaultNext) { return [definition.nodes.find((n) => n.id === node.defaultNext)].filter(Boolean); } throw new Error(`节点 ${node.id} 没有命中条件且没有默认分支`); } // 普通顺序: 取唯一后继 const nextId = Array.isArray(node.next) ? node.next[0] : node.next; const nextNode = definition.nodes.find((n) => n.id === nextId); return nextNode ? [nextNode] : []; }顺序、条件、并行三种跳转都在这一个函数里收敛。先判断 next 是不是数组且长度大于 1,是就按并行拆解,给每个目标节点各创建一个任务;再判断是不是 condition 节点,条件命中则返回命中分支,一个都没命中并且没有 defaultNext 就直接抛错,这个抛错是故意的,让流程定义的问题在设计期暴露,而不是跑着跑着静默停住。
function evaluateExpression(expression, formData) { const { field, op, value } = expression; const actual = formData[field]; switch (op) { case '>': return Number(actual) > Number(value); case '>=': return Number(actual) >= Number(value); case '==': return String(actual) === String(value); case 'in': return Array.isArray(value) && value.includes(actual); default: throw new Error(`不支持的表达式运算符: ${op}`); } }求值器只用白名单操作符,坚决不用 eval 和 new Function。表单数据是用户提交的,直接进 eval 等于给了任意代码执行的口子,这种事故一旦发生没有后悔药。实际业务里最多用到大于、等于、包含三类,覆盖天数判断、金额区间、部门匹配足够。
4.3 驳回与会签的实现细节:回退目标与计数器
async function rejectFlow(conn, task) { const definition = await loadDefinition(task.instance_id); // 找到当前节点的驳回目标, 默认回 start const currentNode = definition.nodes.find((n) => n.id === task.node_id); const targetId = (currentNode && currentNode.rejectTo) || 'start'; // 1. 关闭本实例所有未完成任务, 防止并行分支里残留待办 await conn.query( "UPDATE wf_task SET status = 'canceled', end_time = NOW() WHERE instance_id = ? AND status = 'todo'", [task.instance_id] ); // 2. 实例回到目标节点 await conn.query( 'UPDATE wf_instance SET status = ?, current_node = ? WHERE id = ?', ['running', targetId, task.instance_id] ); // 3. 在目标节点生成新任务 const targetNode = definition.nodes.find((n) => n.id === targetId); if (targetNode) { await createTask(conn, task.instance_id, targetNode); } }驳回的关键是把所有未决任务统一 cancel 掉。并行网关场景下,如果一个人驳回了但另一个分支的待办还在,就会出现「流程已经驳回但待办列表还有红点」的脏状态。rejectTo 指向 start 表示打回重填,指向上级节点表示退回上一级,具体规则由设计器配置,引擎不写死。canceled 是终态,不参与任何待办统计。
会签任务创建时,模式不同,落库方式不同:
async function createTask(conn, instanceId, node) { const mode = node.mode || 'single'; const assigneeList = resolveTarget(node); // [{ userId } | { roleCode }] if (mode === 'all') { // 会签: 只建一行任务, 多人共享这一行, vote_target 达标才放行 const voteTarget = (node.countersign && node.countersign.value) || assigneeList.length || 1; await conn.query( `INSERT INTO wf_task (instance_id, node_id, node_name, assignee, role_code, mode, vote_target, status) VALUES (?, ?, ?, NULL, ?, 'all', ?, 'todo')`, [instanceId, node.id, node.name, node.assignee.roleCode || null, voteTarget] ); return; } // 单签/或签: 候选人各建一行, 谁先处理谁生效 for (const person of assigneeList) { await conn.query( `INSERT INTO wf_task (instance_id, node_id, node_name, assignee, role_code, mode, vote_target, status) VALUES (?, ?, ?, ?, ?, 'single', 1, 'todo')`, [instanceId, node.id, node.name, person.userId || null, person.roleCode || null] ); } }会签不拆多行任务,而是拆「多票」。因为会签的语义是多人对同一事项表决,拆成多行会出现「这个人通过了但那个人的任务还挂着,到底算过还是没过」的裁决难题。一行任务加计数器,reject 直接整体驳回,approve 达到目标票数才放行,一票否决天然支持。会签人数从角色的用户数量取,也可以在节点配置里用 countersign.value 手动指定,业务方改配置就行。
5. 审批流上线前的 5 个避坑点:从重复提交到流程硬编码
前面几章组装起来能跑通,但上线前我建议对照这 5 个坑过一遍,都是我在模拟项目 X 和其他后台系统里见过或踩过的。每一个都按「现象、原因、解决」讲,直接对着查。
5.1 重复提交与并发批准:行锁加状态前置校验
现象:某后台系统提交后快速双击「通过」,接口被调用两次,第一次通过正常,第二次又给下一个节点生成一条重复任务,流程里出现两个相同的待办。
原因:前端按钮没做 loading 是表面原因,根因是接口层没有幂等保护。两个请求先后到达,第一个处理完任务后 status 已变,第二个进来时没检查就直接又走了一遍生成任务的逻辑。
解决:前端按钮置灰是第一步,服务端在 approveTask 里先 SELECT ... FOR UPDATE 锁住任务行,锁住后再次校验 status !== 'todo' 就抛「任务已被处理」。如果用了读写分离,FOR UPDATE 必须走主库查询,走从库会拿到旧数据。
5.2 驳回后流程卡死:回退时清理未决兄弟任务
现象:提交流程后两个节点并行审批,一个节点驳回了,另一个节点的任务还挂在待办列表里,点进去处理时报错或者把流程带到奇怪状态。
原因:reject 逻辑只关闭了当前任务,没管同一实例下其他 todo 任务。并行分支里,驳回只代表其中一条分支否决,其他分支的任务必须被视为无效。
解决:rejectFlow 里按 instance_id 把所有 status='todo' 的任务统一置为 canceled,再按 rejectTo 重建新任务。这条规则是审批流的底线,不做这个清理,任何并行场景都会留脏待办。
5.3 流程定义写死在代码里:改流程要发版
现象:业务方说「审批人从部门经理改成总监」,开发改常量文件重新上线;一个月改七八次,每次都在等发版窗口。
原因:流程定义被当成代码而不是数据。审批链路天然是业务配置,会随组织架构和业务规则频繁变化。
解决:流程 JSON 存库并带 version 字段。实例创建时把 def_version 快照写进 wf_instance,引擎永远按实例上的版本加载定义。修改流程时发布新版本,正在跑的实例不受影响,新实例按新版本启动,这就叫版本化发布。回滚也简单,版本表里留上一个版本的数据就行。
5.4 会签只留总数不留明细:出问题无法追溯
现象:会签节点显示 3 人已同意,但事后复盘发现其中两票是误点,查不到是谁投的,因为任务表里只有 vote_yes 一个总数。
原因:只维护了计数器,没写表决流水。计数器是冗余数据,方便快速判断是否达标,但它不是事实来源。
解决:每次表决先写 wf_record,再更新 vote_yes。查询明细时用 wf_record 按 instance_id 和 node_id 聚合,不要信任务表里的数字。推进到下一节点前,先做一次 record 数量校验,防止计数器被误改后流程错误放行。
5.5 业务表和流程状态绑死:换个流程形态要改表
现象:请假单表里加了 approve_status 字段,后来需求改成「部门审批通过后转 HR 审批」,新增的状态枚举不够用,最后改表加字段,牵一发动全身。
原因:把业务对象状态和流程运行状态塞进同一张表。业务状态是「请假单是否生效」,流程状态是「当前走到哪个审批节点」,两者变化频率不同,不该混存。
解决:业务表只存 biz_key 和 wf_instance_id,流程状态全部放 wf_instance 和 wf_task。引擎到达终态时回调业务模块写业务状态,业务侧不要反向去查流程状态做判断。这样换审批链路、加会签、加条件分支,业务表一行都不用改。
6. 让审批流可回归:一条测试路径覆盖 提交-批准-驳回-会签
审批流最怕的不是写不出来,是改坏了不知道。改了 rejectTo 配置、动了会签计数逻辑,可能影响的是几个月前上线的另一条流程。我的习惯是把引擎跑起来,用一段脚本模拟完整流程路径,每次改完引擎跑一遍。
6.1 用 Node 写流程回归用例
const assert = require('node:assert/strict'); async function testLeaveFlow() { // 1. 提交: 创建实例并生成首任务 const instance = await startInstance('leave_v1', 'user_001', { days: 5 }); let tasks = await listTodoTasks(instance.id); assert.equal(tasks[0].node_id, 'leader_approve'); // 2. 上级通过, days > 3 应进入 HR 审批 await approveTask({ taskId: tasks[0].id, userId: 'user_leader', action: 'approve', formData: { days: 5 } }); tasks = await listTodoTasks(instance.id); assert.equal(tasks[0].node_id, 'hr_approve'); // 3. HR 驳回, 应回到直接上级节点 await approveTask({ taskId: tasks[0].id, userId: 'user_hr', action: 'reject', comment: '材料不全' }); tasks = await listTodoTasks(instance.id); assert.equal(tasks[0].node_id, 'leader_approve'); // 4. 重新提交后进入会签, 首票未达标, 任务应留在会签节点 await approveTask({ taskId: tasks[0].id, userId: 'user_leader', action: 'approve', formData: { days: 5 } }); const hrTasks = await listTodoTasks(instance.id); const instanceAfter = await getInstance(instance.id); assert.equal(hrTasks[0].mode, 'all'); assert.equal(instanceAfter.status, 'running'); } testLeaveFlow().catch((e) => { console.error(e); process.exit(1); });这段脚本把「提交、条件跳转、驳回回退、会签等待」四条关键路径都覆盖了。断言的是节点跳转结果而不是页面响应,所以引擎内部再怎么改,只要对外行为一致,测试就一直是绿的。会签那步故意断言「任务还在会签节点」,就是防止有人把计数器逻辑删了,导致一票就放行。
6.2 把表单渲染也交给流程定义
流程定义 JSON 里可以再加 formSchema 字段,比如审批节点配[{ key: 'days', type: 'number' }, { key: 'reason', type: 'textarea' }],前端审批页根据当前节点的 formSchema 渲染表单,提交时把表单值作为 formData 传给 approveTask 参与条件求值。改表单、改条件都不碰代码。校验规则放 formSchema 里另一个 validators 字段,用数据描述规则,不要把 JS 函数塞进配置。
我最早在模拟项目 X 里做审批流,也在业务表上加过 status,第一次遇到会签加驳回就把自己绕进去了。后来把引擎拆成流程定义、实例、任务、记录四层,所有改状态的操作收进 approveTask 一个入口,流程才能自己走完,不用到处碰业务表。如果你现在正被审批流卡住,先把这条回归脚本建起来,再动引擎,心里才有底。希望帮到你。
本文还有配套的精品资源,点击获取