简介:面向JavaScript工作流与审批流场景的示例代码包,适合有前端基础、希望理解流程引擎与审批界面实现的开发者参考,可用于简易审批系统原型与课程练习。压缩包共49个文件、约69KB,以js脚本、html页面和xml流程定义为主,辅以css样式、gif/png界面素材、aspx与cs后端示例及config配置,流程展示、节点拖拽、权限页和对话框等模块便于按功能查阅。已有1290人学习下载。读者可了解状态机驱动的节点推进、任务分配与权限控制、审批记录展示及前后端协作方式,也能借鉴流程扩展与异常回退思路,适合作为小型审批流的结构参考和二次开发起点。
1. 一张卡了三天的审批单:JS 工作流要解决的远不只是「下一步给谁」
有个审批单停在二级主管那里三天没动,翻日志才发现:一级通过后引擎确实生成了新任务,但审批人字段写的是姓名,账号改名后姓名对不上,任务就挂在一个不存在的用户身上。做 JS 工作流、审批流的项目,真正麻烦的从来不是「点通过跳到下一级」,而是任务生成得准、分支判断得对、并发点两次不出错、流程改版后老单据还能按老规则跑完。这套方案要解决的就是这些:用一份 JSON 流程定义驱动前后端、用 Node 侧引擎负责状态推进、用五张表撑住定义与实例的分离,适合正在做审批流毕设、课设,或者要给内部系统补一套审批能力的人参考。下面从需求约束一路讲到验收答辩,结构和代码都能直接照着改。
2. 需求拆解与技术选型:为什么不能只写一个状态机
2.1 审批流的四个真实约束
很多人接到「审批流」需求,第一反应是写一个状态机:草稿 → 待审批 → 通过 / 驳回。这个模型在单人单级的场景里能跑通,但只要业务方补三句话就崩了:需要两级审批、金额超过 5000 要加一级总监、财务和人资要同时签。这时候状态机里的每个状态都要加分支,代码会迅速变成一坨 if-else。
我把这类需求归成四个约束,后面所有设计都围绕它们展开。
第一是可变性。流程不是写死的,业务方会要求加节点、改审批人、调条件阈值,而且改完不能影响已经在跑的单据。这就逼着把「流程长什么样」从代码里抽出来,变成可存储、可版本化的数据。
第二是多人性。会签(都要签)、或签(一人签就行)、加签(审批过程中临时拉人)、转办(换个人处理),这些不是边缘功能,是审批流的日常。任务不能是一对一的,得是一对多且带合并策略。
第三是可追溯。谁在什么时候改了什么、驳回退回到哪一级、当时的表单数据长什么样,出问题时要能一帧一帧回放。审批记录不是日志,是业务数据,要能查、能导出、能在页面上画成时间轴。
第四是一致性。同一个人手抖点两次「同意」,不能让流程跳两级;两个会签人同时提交,不能一个覆盖另一个的结果。这类并发问题在演示时基本遇不到,一上真实环境就是血泪经验。
把这四条摆出来,技术选型的方向就清楚了:需要一个「定义」和一个「实例」两层模型,定义描述流程长什么样,实例记录这一次跑到哪了。状态机只适合当实例内部的状态字段,不适配当整体架构。
2.2 技术栈取舍:Node 侧引擎 + 前端 JSON 驱动渲染
技术栈这块,前端 Vue 或 React 都行,后端 Node(Express 或 NestJS)配 MySQL / PostgreSQL,这套组合在课程设计和中小型项目里最省事,因为前后端共用一份 JavaScript 心智模型,流程定义的 JSON 结构可以在两端复用。
关键取舍在引擎放哪。见过一些实现把流转逻辑写在前端:点「同意」之后前端自己算出下一级审批人,再调接口把结果存回去。这种写法在演示时特别顺,因为不用等后端返回,但它有三个致命问题:前端能改流程就意味流程不可信;换个小程序端或第三方调用,逻辑要重写一遍;审批人的计算规则一旦涉及组织架构查询,前端拿不到完整数据。
所以这套方案的划分是:后端持有唯一权威的流程定义和流转逻辑,前端只负责渲染和收集输入。前端拿到的流程 JSON 是只读的,画流程图、渲染表单、显示当前节点高亮都基于这份数据,但点「同意」时前端只发一个动作和三五个字段,剩下全交给后端算。
具体到库的选择,我给的建议是能少依赖就少依赖。流程图的渲染用 SVG 手写或轻量图库足够,别为了一个审批流引入重型可视化框架;表达式求值自己写一个白名单求值器(后面 4.2 会贴),比引入表达式引擎更可控;ORM 用 Knex 或 Prisma 都行,重点是事务要能显式控制,因为审批动作必然涉及多表写入。
2.3 能力边界:哪些交给引擎,哪些留给业务代码
这一节是我觉得最容易被忽略、但决定了后期维护成本的部分。很多项目翻车不是因为引擎写得不好,而是因为把不该引擎管的事塞进了引擎。
我一般按这条线切:引擎只管「流程怎么走」,不管「业务对不对」。具体说,引擎负责的事情包括:根据当前节点和变量算出下一批节点、生成和关闭任务、维护实例状态、记录流转历史、处理会签合并和驳回回退。这些是通用能力,换任何业务都一样。
业务代码负责的事情包括:审批人怎么来(查组织架构、查角色、查上级)、表单字段怎么校验(金额不能为负、附件必填)、审批通过后触发什么副作用(改订单状态、发通知、写业务表)。这些一旦塞进引擎,引擎就会变成一个大泥球。
举个具体的边界例子:节点上配置的assigneeType: 'ROLE'、assigneeValue: 'finance_manager',引擎只负责把这段配置解析出来,然后调用一个注册进来的resolveAssignees(config, instance)回调,拿到具体的用户 ID 列表。至于这个角色下面有几个人、某个人离职了怎么办,是业务侧的事。这样引擎可以复用到不同业务线,业务侧改组织架构也不用动引擎代码。
边界划清之后,还有一个好处:测试好写。引擎的测试只需要构造流程定义和变量,断言下一批节点是什么,不用起数据库、不用 mock 组织架构。这部分单测覆盖率做到 80% 以上,整个项目的稳定性就有底了。
3. 总体架构设计:五张表、三层结构与一次审批的完整链路
3.1 数据模型:五张表撑起定义、实例与任务
数据模型是整个方案的地基,改起来最贵,所以先把它定死。核心思路是定义和实例彻底分离:定义表存「流程模板」,实例表存「这一次运行」,两者靠definition_id + definition_version关联。
五张表的分工是这样:流程定义表存模板元信息和表单结构;节点定义表存每个节点的类型、审批人配置、分支条件;流程实例表存一次运行的整体状态和变量快照;任务表存待办和已办;流转记录表存每一步的动作历史。
-- 流程定义:一个 code 可以有多个 version,新版本不影响在跑的实例 CREATE TABLE wf_definition ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(64) NOT NULL COMMENT '流程编码,如 leave_apply', name VARCHAR(128) NOT NULL, version INT NOT NULL DEFAULT 1, status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ACTIVE/DISABLED', form_schema JSON NOT NULL COMMENT '表单字段定义,前端据此渲染', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_code_version (code, version) ); -- 节点定义:next_config 存后继节点,条件分支存 branches CREATE TABLE wf_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT, definition_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL COMMENT '节点唯一标识,如 manager_approve', node_type VARCHAR(16) NOT NULL COMMENT 'START/APPROVAL/CC/CONDITION/END', name VARCHAR(128) NOT NULL, assignee_type VARCHAR(16) NULL COMMENT 'USER/ROLE/DEPT_LEADER/FORM_FIELD', assignee_value VARCHAR(255) NULL, next_config JSON NULL COMMENT '后继节点与分支规则', props JSON NULL COMMENT '会签策略、超时提醒等扩展属性', UNIQUE KEY uk_def_node (definition_id, node_key) ); -- 流程实例:variables 存业务变量,version 用于乐观锁 CREATE TABLE wf_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, definition_id BIGINT NOT NULL, definition_ver INT NOT NULL COMMENT '锁版本,老实例永远跑老定义', biz_key VARCHAR(64) NOT NULL COMMENT '业务单据号,如 leave_20240501_001', initiator VARCHAR(64) NOT NULL, current_nodes JSON NOT NULL COMMENT '当前停留节点,会签时可能多个', status VARCHAR(16) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/APPROVED/REJECTED/CANCELED', variables JSON NOT NULL COMMENT '业务变量 + 表单数据', version INT NOT NULL DEFAULT 0, started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, ended_at DATETIME NULL, UNIQUE KEY uk_biz_key (biz_key) ); -- 任务:一个节点可以生成多条(会签),assigned_to 存账号 ID 不存姓名 CREATE TABLE wf_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, assigned_to VARCHAR(64) NOT NULL COMMENT '账号 ID,不要存姓名', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/REJECTED/TRANSFERRED', version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME NULL, KEY idx_assignee_status (assigned_to, status) ); -- 流转记录:每一步动作留痕,snapshot 存动作发生前的变量 CREATE TABLE wf_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, task_id BIGINT NULL, node_key VARCHAR(64) NOT NULL, action VARCHAR(16) NOT NULL COMMENT 'APPROVE/REJECT/WITHDRAW/TRANSFER/ADD_SIGN', operator VARCHAR(64) NOT NULL, comment VARCHAR(500) NULL, action_key VARCHAR(64) NULL COMMENT '幂等键,前端一次点击生成一个', snapshot JSON NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_task_action (task_id, action_key) );几个字段值得单独说明。wf_instance.definition_ver是后悔药:流程定义升级到 v2 之后,正在跑的 v1 实例仍然读 v1 的节点配置,避免审批到一半发现下一级节点没了。wf_task.assigned_to存账号 ID 而不是姓名,就是开头那个事故的直接修正。wf_record.snapshot存动作发生前的变量快照,驳回时用它回滚,不用重新算。
current_nodes用 JSON 而不是单个字段,是因为会签场景下一个实例可能同时停在多个任务上,甚至条件分支并行时可能停在两个不同节点,用数组能覆盖住。
3.2 引擎分层:解析层、调度层、任务层
引擎内部拆三层,每层职责单一,出问题时能快速定位是哪一层的事。
解析层负责把数据库里的定义读出来,组装成一个内存里的图结构,做一次完整性校验:有没有孤立节点、有没有环、条件分支是否都有默认分支兜底、结束节点是否存在。校验放在流程发布时做,不放在运行时做,因为运行时做校验既慢又容易把问题变成 500 错误。发布时校验不通过直接拒绝发布,这条很省事。
调度层是核心,输入是「当前节点 + 变量」,输出是「下一批节点」。它处理条件分支的求值、并行网关的展开、结束节点的收敛。这一层是纯函数式的,不碰数据库,好测也好复现。
任务层负责把调度层的输出落成实际的待办任务:解析审批人、批量插入任务、更新实例的当前节点和版本号、写流转记录。这一层全部包在事务里,任何一步失败整体回滚。
分层之后有个隐性收益:排查问题有明确路径。用户反馈「我的审批没收到」,先看任务表有没有记录(任务层),有记录说明审批人解析有问题;没记录就看流转记录里上一步动作后的节点是不是对的(调度层),节点不对就是条件分支或定义数据的问题;再看定义本身对不对(解析层)。这条路径写在文档里,团队里任何一个人都能照着查。
3.3 一次审批请求的完整调用链与幂等设计
把一次「同意」按钮的请求拆开看,链路是这样的:前端提交{ taskId, action, comment, formData, actionKey };后端先做权限校验(这个任务是不是派给当前登录人的),再做幂等校验(actionKey是否已存在),然后在事务里锁任务行、更新任务状态、调用调度层算下一批节点、解析审批人、生成新任务、更新实例、写流转记录,最后提交事务并异步触发通知。
幂等这块必须用两道保险,只靠一道迟早出问题。
-- 第一道:数据库唯一索引,同一个任务同一次点击只允许落一条记录 CREATE UNIQUE INDEX uk_task_action ON wf_record (task_id, action_key); -- 第二道:状态 CAS,只有 PENDING 状态才能被更新,且版本号要匹配 UPDATE wf_task SET status = 'APPROVED', finished_at = NOW(), version = version + 1 WHERE id = ? AND status = 'PENDING' AND version = ?; -- 影响行数为 0 就直接返回「该任务已被处理」,不要抛 500actionKey由前端在页面渲染时生成一个 UUID,重试时复用同一个值。这样用户双击、网络超时重发、用户手动刷新再提交,三种情况都会被挡在唯一索引上。SQL 里那句「影响行数为 0 就返回友好提示」是给产品体验兜底的,因为有些场景actionKey拿不到(比如第三方系统调用),只能靠 CAS 拦。
事务里锁任务行这一步不能省。两个会签人几乎同时点同意,如果不加行锁(SELECT ... FOR UPDATE),两边都读到PENDING,都会尝试走下一步,就会出现同一个节点生成两批任务的情况。这类问题在本地开发时基本测不出来,因为人的手速跟不上。
4. 核心模块实现:条件分支、会签、驳回怎么落代码
4.1 流程定义 DSL:一份 JSON 同时喂给前端画图和后端执行
流程定义用 JSON 描述,结构要让前端能画图、后端能执行,两者共用一个结构才不会出现「图上画的」和「实际走的」不一致。
{ "code": "leave_apply", "version": 1, "nodes": [ { "key": "start", "type": "START", "next": ["first_approve"] }, { "key": "first_approve", "type": "APPROVAL", "assigneeType": "DEPT_LEADER", "assigneeValue": "initiator", "props": { "countersign": "ANY" }, "next": ["check_amount"] }, { "key": "check_amount", "type": "CONDITION", "branches": [ { "to": "director_approve", "rule": { "field": "form.amount", "op": "gt", "value": 5000 } }, { "to": "hr_cc", "rule": { "field": "form.amount", "op": "lte", "value": 5000 } } ], "defaultTo": "hr_cc" }, { "key": "director_approve", "type": "APPROVAL", "assigneeType": "ROLE", "assigneeValue": "director", "props": { "countersign": "ALL" }, "next": ["hr_cc"] }, { "key": "hr_cc", "type": "CC", "assigneeType": "ROLE", "assigneeValue": "hr", "next": ["end"] }, { "key": "end", "type": "END" } ] }props.countersign声明这个节点是或签还是会签,引擎按它决定合并策略;branches里的rule是结构化规则对象而不是字符串表达式,这样前端能做可视化配置,后端也省得解析字符串。defaultTo是兜底分支,所有条件都不命中时走它——这个字段凡是漏掉的项目,最后都会在某个边界数据上卡死。
这份 JSON 存进wf_definition.form_schema和wf_node表,发布时做一次图校验:用深度优先遍历检查是否所有节点都从start可达、是否存在环、每个 CONDITION 节点是否有defaultTo。校验失败的流程不允许发布,直接在编辑页给红字提示。
前端的流程图渲染就直接读这份 JSON,节点坐标可以按层级自动布局,也可以用一份单独的layout字段存手工拖动后的坐标。关键是前端不参与任何流转判断,它只需要根据current_nodes高亮当前节点、根据status上色。
4.2 条件分支求值:不用 eval 也能跑表达式
条件求值最省事的写法是new Function('vars', 'return ' + expr),但这条路我不建议走。一是安全问题,流程定义虽然是后台配置的,但配置权限一旦放开给业务方,就等于开了个执行任意代码的口子;二是可维护性,字符串表达式写错了只能在运行时才发现,而且同一个表达式在不同订单上行为可能因为类型不一致而漂移。
替代方案是用结构化的规则树加白名单操作符,代码量不大但可控。
// 白名单操作符,未列出的操作符直接报错,杜绝任意执行 const OPS = { eq: (a, b) => a === b, neq: (a, b) => a !== b, gt: (a, b) => Number(a) > Number(b), gte: (a, b) => Number(a) >= Number(b), lt: (a, b) => Number(a) < Number(b), lte: (a, b) => Number(a) <= Number(b), in: (a, b) => Array.isArray(b) && b.includes(a), contains: (a, b) => Array.isArray(a) && a.includes(b), exists: (a) => a !== undefined && a !== null && a !== '' }; // 从嵌套对象取值,路径形如 form.amount、applicant.deptId function getByPath(obj, path) { return String(path).split('.').reduce((acc, k) => (acc == null ? acc : acc[k]), obj); } // rule 支持三种形态:叶子规则 / and / or / not function evalRule(rule, vars) { if (!rule || typeof rule !== 'object') return false; if (rule.op === 'and') return rule.items.every(r => evalRule(r, vars)); if (rule.op === 'or') return rule.items.some(r => evalRule(r, vars)); if (rule.op === 'not') return !evalRule(rule.items[0], vars); const fn = OPS[rule.op]; if (!fn) throw new Error(`不支持的操作符: ${rule.op}`); return fn(getByPath(vars, rule.field), rule.value); } module.exports = { evalRule, getByPath };field用点号路径取值,跟表单 schema 里的字段名一一对应,业务方在配置页选字段时下拉的选项就是从 schema 里生成的,不会出现填了个不存在的字段名。and/or的items是数组,可以无限嵌套,覆盖绝大多数组合条件。
这段代码的测试很好写:给定变量对象和规则对象,断言布尔值。有个容易漏的点是类型一致性,从表单提交上来的金额可能是字符串"6000",而规则里配的是数字5000,所以gt/lt这几个比较操作符里我都做了Number()转换。不做这一步,"6000" > 5000在某些路径下会得到意外结果,属于典型的玄学 bug。
4.3 会签与或签:多人审批的合并策略
会签的本质是「一个节点下挂多个任务,所有人处理完再决定这个节点是过还是不过」。合并策略必须显式配置,不能靠默认值,因为不同节点的诉求完全不同:财务会签通常要全票通过,技术评审可能是任意一人通过即可,还有按比例通过的场景。
// 合并策略:ALL 全票 / ANY 一票 / RATIO 按比例 function judgeNode(tasks, strategy, ratio = 1) { const total = tasks.length; const approved = tasks.filter(t => t.status === 'APPROVED').length; const rejected = tasks.filter(t => t.status === 'REJECTED').length; switch (strategy) { case 'ALL': // 一票否决立即终止,不必等剩下的人慢慢点 if (rejected > 0) return 'REJECTED'; return approved === total ? 'APPROVED' : 'PENDING'; case 'ANY': if (approved > 0) return 'APPROVED'; return rejected === total ? 'REJECTED' : 'PENDING'; case 'RATIO': // ratio = 0.6 表示 60% 通过即过;否决线是 1 - ratio if (approved / total >= ratio) return 'APPROVED'; if (rejected / total > 1 - ratio) return 'REJECTED'; return 'PENDING'; default: throw new Error(`未知会签策略: ${strategy}`); } }每次有人处理任务后,都要把该节点下的全部任务捞出来跑一遍judgeNode,返回APPROVED或REJECTED才推进实例,返回PENDING就什么都不做,只更新任务状态。这段逻辑放在事务里,配合任务行的FOR UPDATE,能挡住并发提交。
有个细节值得说:ALL策略下的一票否决,我选择了「立即终止」而不是「等所有人签完再算」。理由是审批人看到已经有人否决了还继续签,体验很差。但如果业务上要求完整留痕(比如需要记录每个人的意见),就得改成「标记否决但继续收集」,这时候实例状态要加一个REJECTING中间态,等所有人签完再落终态。
加签和转办是这套模型的自然延伸:加签就是往当前节点插一条新的PENDING任务,总数变了重新跑合并判断;转办是把某条任务的assigned_to换掉并记一条记录,任务数不变。两个功能都不需要动引擎主干,只加两个接口。
4.4 驳回与撤回:回退节点时变量怎么处理
驳回是审批流里最容易做错的功能,因为大部分实现只回退了节点,没回退数据。
设想一个场景:申请人提交请假 3 天,一级审批通过,二级审批时主管把天数改成 2 天然后驳回给申请人。如果只回退节点不回退变量,申请人再提交时表单里还是 3 天,但流程里残留的是 2 天的变量,两边对不上,最后算假期就出错。
处理方式是驳回时按wf_record.snapshot把变量恢复到「进入目标节点时」的状态。
// 驳回:指定回退目标节点,变量按快照恢复,全程一个事务 async function rejectTask(trx, { instance, task, targetNodeKey, targetSnapshot, operator, comment }) { // 1. 关掉当前所有 PENDING 任务(会签场景下可能有多个) await trx('wf_task') .where({ instance_id: instance.id, node_key: task.node_key, status: 'PENDING' }) .update({ status: 'REJECTED', finished_at: trx.fn.now() }); // 2. 实例回到目标节点,变量按快照回滚,版本号 +1 做乐观锁 const affected = await trx('wf_instance') .where({ id: instance.id, version: instance.version }) .update({ current_nodes: JSON.stringify([targetNodeKey]), variables: JSON.stringify(targetSnapshot), status: 'RUNNING', version: instance.version + 1 }); if (affected === 0) throw new Error('实例已被其他操作修改,请刷新重试'); // 3. 在目标节点重新生成待办任务(审批人按目标节点配置重新解析) const assignees = await resolveAssignees(targetNodeKey, targetSnapshot); await trx('wf_task').insert(assignees.map(uid => ({ instance_id: instance.id, node_key: targetNodeKey, assigned_to: uid, status: 'PENDING' }))); // 4. 留痕,记录动作和操作人 await trx('wf_record').insert({ instance_id: instance.id, task_id: task.id, node_key: task.node_key, action: 'REJECT', operator, comment, snapshot: JSON.stringify(instance.variables) }); }四个参数里targetNodeKey是驳回目标,targetSnapshot是从流转记录里翻出来的历史变量。第 2 步的affected === 0判断不能省,多人同时操作同一实例时它是最后一道闸。第 3 步重新解析审批人而不是复用旧任务,是因为组织架构可能变了,回退到一级时一级主管可能已经换人。
撤回是另一个方向:只有发起人能撤,且只能撤还没人处理过的实例。判断条件是「该实例所有任务都是 PENDING 且wf_record里没有 APPROVE 动作」,满足就置status = 'CANCELED'并关掉所有任务。撤回不需要回滚变量,因为它本来就还没走过任何审批。
5. 避坑与排查:审批流最容易翻车的五个地方
5.1 现象:流程图上线后和实际走的分支对不上
现象:配置页面上画的分支清清楚楚,实际跑的时候却走了另一条,日志里也看不出为什么。
原因:前端画图和后端执行用了两套判断逻辑,或者前端展示的条件文案和后端规则对象不是同一份数据,改了一边忘了另一边。
解决:后端是唯一权威,前端只做渲染。画图用的分支条件和执行用的rule必须是同一份 JSON 里读出来的,不允许前端做任何二次加工。再加一条:流程发布时把定义 JSON 存档,实例运行时把实际命中的分支写进wf_record,出问题直接看这条记录里存的是哪个分支,不用猜。
5.2 现象:审批人收到任务但打不开,或者根本没收到
现象:任务表里有记录,但审批人登录后待办列表是空的;或者任务表里压根没记录,流程却显示已推进。
原因:前者通常是任务里存的是姓名或工号,而待办查询按账号 ID 过滤,两者对不上;后者是审批人解析返回了空数组,引擎当成「没人可派」直接跳过节点继续往下走了。
解决:任务表只存账号 ID,姓名在展示层 join。审批人解析返回空数组时必须报错并卡住流程,不能静默跳过——很多项目就是因为静默跳过,导致某个部门没人配角色时审批直接流到了终点,事后完全无法追责。
5.3 现象:同一个节点生成了两批任务,或者流程图里出现两条并行线
现象:用户点了一下同意,下一级出现了两条一模一样的待办;或者日志显示实例被推进了两次。
原因:缺幂等键或没做行锁,双击、网络重发、浏览器回退重提,三种情况都会触发重复提交。也有可能是前端在提交成功前把按钮重新启用了一次。
解决:三层防护一起上。前端提交时生成actionKey并在请求期间禁用按钮;数据库加(task_id, action_key)唯一索引;更新任务用status = 'PENDING'加版本号做 CAS,影响行数为 0 直接返回友好提示而不是 500。三条中任何一条单独用都不够。
5.4 现象:流程定义改了版本,正在跑的单据全部卡死
现象:业务方要求加一级审批,发布新版本后,之前跑到一半的单据全部报「节点不存在」。
原因:实例只存了definition_id,读定义时默认读了最新版本,老实例拿着老的节点 key 去新定义里找,自然找不到。
解决:实例上必须冗余definition_ver,运行时按(definition_id, definition_ver)精确读取,老实例永远跑老定义。新版本只对发布之后创建的实例生效。这条在数据库设计阶段就要定,事后补极其痛苦。
5.5 现象:驳回之后重新提交,数据前后不一致
现象:申请被驳回,修改后再次提交,但审批页面上显示的有些字段是旧的,有些是新的,或者金额、天数出现莫名奇妙的偏差。
原因:驳回了节点但没回滚变量,或者回滚了变量但重新提交时只覆盖了部分字段,两边拼在一起就成了四不像。
解决:驳回时按快照整体回滚变量,不接受增量回滚;重新提交时以表单全量覆盖variables里的form节点,其余引擎变量(发起人、单据号)保持不变。另外在流转记录里存每次动作前的完整快照,出问题可以逐帧回放,这比翻应用日志高效得多。
6. 验收与答辩:用一条单据跑通整条证据链
验收阶段最怕的是「能跑就行」。演示时点一遍通过就结束,评委或测试同学随便问一个问题就露馅。我的做法是准备一条固定的验收单据,从提交到结束走完整流程,每一步都留可验证的证据。
先给一份我常用的验收清单,按功能点逐条打勾,每一条都要能当场演示,而不是口头说明。
| 验收项 | 操作方式 | 通过标准 |
|---|---|---|
| 条件分支 | 用金额 4000 和 6000 各提交一单 | 两单进入不同审批节点,wf_record里记录的分支不同 |
| 或签 | 同一节点配两个审批人,一人通过 | 节点直接过,另一人的任务自动关闭 |
| 会签 | 配 ALL 策略,一人驳回 | 节点立即置为 REJECTED,实例状态同步变更 |
| 幂等 | 连续快速点击同意两次 | 只产生一条流转记录和一批下一级任务 |
| 并发 | 两个会签人同时提交 | 合并结果正确,不出现重复任务 |
| 驳回 | 驳回一级并修改表单字段 | 变量回到进入该节点前的状态,重新提交后数据一致 |
| 版本隔离 | 发布 v2 后查看 v1 实例 | 老实例仍按 v1 节点配置运行,不报错 |
| 留痕 | 查看流转记录时间轴 | 每一步动作、操作人、意见、快照齐全 |
答辩时我建议按「问题 → 模型 → 关键代码 → 证据」四段讲,时间控制在 8 分钟以内。问题部分就讲开头那个卡三天的例子,三十秒把听众带进场景;模型部分画一次五张表的关系,重点讲为什么定义和实例要分离、为什么实例要锁版本;关键代码挑两段,一段是条件求值的白名单操作符,一段是幂等那两条 SQL,这两处最能体现设计深度;最后是证据,现场跑一遍验收清单里的幂等和会签两项,比讲十页 PPT 有用。
答辩最常被问的三个问题,提前想好答案:为什么不用现成的流程引擎——答通用引擎的配置和二次开发成本在中型项目里往往超过自研,而且这套方案的核心逻辑只有六百多行,可控性更高;会签并发怎么保证——答行锁加 CAS 加唯一索引三层;流程改了老单子怎么办——答实例锁版本号。这三个问题答得利索,基本就稳了。
从那以后我每次做流程类项目,第一件事就是把五张表先画出来,把定义和实例的边界定死,再动一行代码;验收清单也一定在开发前写,不是开发完补。这套习惯救过我很多次。希望帮到你。
本文还有配套的精品资源,点击获取