从状态机到幂等:轻量级JS审批流引擎设计与实现

发布时间:2026/10/29 23:14:33
从状态机到幂等:轻量级JS审批流引擎设计与实现 简介面向前端与业务系统开发者的JavaScript工作流与审批流示例代码包聚焦流程建模、任务分配、审批界面交互、状态持久化与异常回退等常见问题适合具备一定JavaScript基础、希望理解轻量级审批流程实现的学习者。压缩包共49个文件以js脚本、html页面、xml流程定义和aspx/cs后端代码为主辅以css样式、gif/png图示及少量数据库与配置文件整体约69KB。已有1290人学习下载。读者可结合页面脚本与流程配置观察提交、审核、批准或拒绝等节点如何由状态变迁驱动并参考角色权限控制、Ajax数据同步、审计日志与流程回退思路用于课程设计、内部工具原型验证或二次开发。1. 审批流并不是只画一张流程图就结束三个真实卡点先摆出来一个请假单要走三级审批需求方说“画个流程图就行”真做起来才发现难的不是画图退回之后谁知道该回到哪一节点、会签三个人里两个同意算不算通过、上月发起的流程遇到改版是按旧规则还是新规则继续执行。这套 js 工作流 / 审批流方案把这些问题统一放进一个可以复用的轻量引擎里——流程定义用 JSON 描述流转规则由服务端统一裁决前端只负责渲染表单和展示审批轨迹。它适合两类人正在做审批类毕设或课程设计、需要一套讲得清也跑得动的结构以及要给内部管理系统补上审批能力、又不想引入重型引擎的开发者。下面的模块划分、表结构和状态机可以照搬改判断条件就能用在自己的项目里。2. 需求拆解与技术选型为什么最后没用现成的 BPMN 引擎2.1 把「审批」拆成四类动作需求才立得住不少方案失败在需求阶段只写了“支持多级审批”没写清楚“谁能在什么时候做什么”。我一般先把用户操作收敛成四类动作再往下延伸需求边界一下就清晰了。动作触发者影响范围必须写入数据库的副作用发起申请人首节点审批人创建实例、创建任务、写一条记录处置审批人当前节点关闭任务、判定节点是否终结、写记录流转审批人 / 管理员节点或人员插入任务、修改任务归属、写记录终止申请人 / 定时任务整个实例关闭实例、关闭未办任务、写记录四类动作之外还有一批“看起来是细节、其实是需求”的点必须在设计前定死否则写到一半返工审批人怎么算指定人、角色、发起人自选、部门负责人、上级的上级会签的通过阈值是全员同意还是过半同意一人驳回是整节点否决还是只否决自己那一票退回是退回上一节点、退回发起人还是退回任意历史节点退回后原审批记录保留还是清空超时是自动通过、自动提醒还是转交。这六条每一条都会改动表结构或状态机越晚确认代价越大。我通常会把上面这些整理成一张“动作 × 规则”的确认表让需求方逐条勾选勾完再动手建表。这样做还有个额外好处答辩时这张表本身就是需求分析的证据比一句“实现了多级审批”有说服力得多。2.2 引擎选型的三种方案与取舍能走的路其实只有三条各有各的代价。方案优点代价适用场景引入开源 BPMN 引擎规范完整支持并行网关、子流程Node 生态可选少多数要跨语言部署配置复杂出问题难定位流程极其复杂、有多系统集成诉求用低代码平台的审批组件上手快表单和流程可视化现成逻辑不掌握在自己手里定制判断条件受限迁移成本高内部轻量场景、追求交付速度自研轻量引擎全栈同语言每一行都能讲清改判断条件只是改配置复杂网关、子流程要自己补审批层级 ≤ 5 级、以串行和会签为主我选第三条理由很实际审批场景里 80% 的需求是“串行节点 会签 条件分支 退回”并行网关和子流程属于长尾而全栈统一在 JavaScript 上前端渲染表单、后端算审批人、调试时同一套断点联调成本低一大截。代价也要认不做跨流程调用不做动态子流程不做图形化拖拽建模用 JSON 手写定义 一个只读的流程图预览就够了。自研最容易被低估的是“能力边界”这件事。开工前我会明确写死一份不做清单不支持并行网关、不支持运行时改流程结构、不支持一个实例跨两个流程定义。写在文档里后面谁提需求都能指着这行字拒绝比事后扯皮省力。2.3 前后端职责边界谁算下一节点边界划错是另一个常见翻车点。有的实现把“下一个审批人是谁”放在前端算前端传nextApprover给后端结果后端变成了单纯的存储层——只要改一下请求体就能把审批单直接送到自己账号上。审批人计算、条件判定、节点完成判定这三件事必须在服务端前端只做三件事渲染表单、发请求、展示轨迹。接口按这个边界设计一共五个就够用方法路径作用关键参数POST/api/flow/start发起流程defKey、bizId、formDataGET/api/flow/todo我的待办page、sizePOST/api/flow/task/:id/approve审批动作action、comment、requestIdPOST/api/flow/instance/:id/revoke撤销reasonGET/api/flow/instance/:id/timeline审批轨迹无requestId这个字段从第一天就加上别等上线后出现重复审批再补。它在前端由一次点击生成、在服务端落唯一索引是幂等的唯一抓手后面第 5 章会详细说它挡住了什么。3. 数据模型与状态机五张表撑起一条可回放的审批链3.1 流程定义与实例的版本隔离流程定义是会变的实例是不能变的。这两件事必须分表而且实例要把“我按哪一版定义在跑”记下来否则改一次流程所有在途单据的轨迹全乱。-- 流程定义同一个 def_key 允许多版本共存只有 PUBLISHED 的能发起新实例 CREATE TABLE flow_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, def_key VARCHAR(64) NOT NULL COMMENT 流程标识如 leave_apply, name VARCHAR(128) NOT NULL, version INT NOT NULL DEFAULT 1, schema_json JSON NOT NULL COMMENT 节点、连线、审批人策略、条件, status VARCHAR(16) NOT NULL DEFAULT DRAFT COMMENT DRAFT/PUBLISHED/ARCHIVED, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_key_version (def_key, version) ); -- 流程实例def_version 是快照锚点运行时永远按这个版本取定义 CREATE TABLE flow_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, def_key VARCHAR(64) NOT NULL, def_version INT NOT NULL, biz_id VARCHAR(64) NOT NULL COMMENT 业务单据 ID用于回查业务数据, starter_id VARCHAR(64) NOT NULL, state VARCHAR(16) NOT NULL DEFAULT RUNNING, round INT NOT NULL DEFAULT 1 COMMENT 轮次退回重提时 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_biz (def_key, biz_id) );schema_json存整份定义而不拆成节点表和连线表是权衡后的选择查询永远按整份取拆表反而多两次 join流程定义体量小几十 KB 以内放 JSON 字段完全够用。round这个字段现在看起来多余等到实现“退回后重新提交、历史记录要保留”时它是唯一能把两轮审批区分开的东西。3.2 任务表与审批记录表的分工这两张表性质完全不同任务表存“还没发生的事”会被修改记录表存“已经发生的事”只追加。混在一起写就会出现在办任务被 update 覆盖、审批历史查不出来的问题。-- 待办任务只关心当前该谁办办完就置为终态不复用 CREATE TABLE flow_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, round INT NOT NULL DEFAULT 1, assignee_id VARCHAR(64) NOT NULL, task_type VARCHAR(16) NOT NULL DEFAULT NORMAL COMMENT NORMAL/ADD_SIGN, status VARCHAR(16) NOT NULL DEFAULT PENDING COMMENT PENDING/DONE/CANCELED, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_assignee (assignee_id, status), KEY idx_instance (instance_id, node_key, round) ); -- 审批记录append-only不提供任何 UPDATE 入口 CREATE TABLE flow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, task_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, round INT NOT NULL, handler_id VARCHAR(64) NOT NULL, action VARCHAR(16) NOT NULL COMMENT APPROVE/REJECT/RETURN/ADD_SIGN/REVOKE, comment VARCHAR(512) DEFAULT NULL, request_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (task_id, request_id) );uk_request这个唯一索引就是幂等的最后一道保险。前端一次点击只生成一个requestId即使请求被重发三次第二、三次也会直接撞唯一键失败业务层捕获后返回“已处理”不会多出一条记录、也不会多推进一次流程。审批轨迹的渲染完全基于flow_recordround按instance_id查出全部记录按round分组组内按时间排序前端就能画出“第 1 轮张三同意 → 李四退回第 2 轮张三同意 → 李四同意 → 王五同意”这样的时间线。这也是为什么记录表绝对不能更新——它承担的是审计职责。3.3 状态机与合法迁移状态越多越不能靠散落在各处的 if 判断。我的做法是把合法迁移写成一张白名单表任何状态变更先过校验非法迁移直接抛错。// 实例状态机只有白名单内的迁移才放行避免出现「已撤销还能审批」这类脏数据 const INSTANCE_TRANSITIONS { RUNNING: [APPROVED, REJECTED, REVOKED, TERMINATED], REJECTED: [RUNNING], // 退回后重新提交回到 RUNNING同时 round 1 APPROVED: [], REVOKED: [], TERMINATED: [] }; function assertTransition(from, to) { const allowed INSTANCE_TRANSITIONS[from] || []; if (!allowed.includes(to)) { const err new Error(非法状态迁移: ${from} - ${to}); err.code ILLEGAL_TRANSITION; err.status 400; // 交给统一错误中间件转成 HTTP 400 throw err; } }参数说明from是当前状态必须从数据库里读出来的值不能信前端传的to是本次操作期望到达的状态err.status用来让上层中间件区分“业务规则拒绝”400和“系统异常”500。用白名单而不是 if-else 的好处是可枚举、可测试——写个单测把 5 个状态两两组合跑一遍25 条断言5 分钟就能覆盖完比人工点击可靠得多。3.4 流程定义的 JSON 结构定义体我只保留四类信息节点、连线、审批人策略、条件。网关不单独建模条件直接挂在连线上这样一份定义用眼睛就能读完。{ key: leave_apply, version: 3, nodes: [ { key: start, type: START, name: 发起 }, { key: leader, type: APPROVE, name: 直属主管, approveType: ANY, assignee: { type: DEPT_LEADER } }, { key: countersign, type: APPROVE, name: 跨部门会签, approveType: ALL, passRatio: 1, assignee: { type: ROLE, value: HRBP } }, { key: end, type: END, name: 结束 } ], edges: [ { from: start, to: leader }, { from: leader, to: countersign, condition: days 3 }, { from: leader, to: end, condition: days 3 }, { from: countersign, to: end } ] }approveType取ANY或签一人同意即过、ALL会签全员同意、SEQ依次审批按 assignee 列表顺序逐个生成任务。passRatio留了扩展位设成 0.5 就是过半通过答辩时被问“能不能半数通过”不用改结构改值就行。condition是一段受控表达式下一章讲它怎么安全求值。4. 核心引擎实现从当前节点算出下一批审批人4.1 节点流转一次审批请求的完整执行链所有审批动作走同一个入口函数事务包住“关任务 → 写记录 → 判节点 → 找下一节点 → 建任务 → 改实例”六步。任何一步失败整体回滚不留下“任务关了但记录没写”的中间态。async function completeTask({ taskId, userId, action, comment, requestId }) { return db.transaction(async (tx) { // 1) 用状态条件做占位式更新affectedRows 为 0 说明任务已被别人处理 const [r] await tx.query( UPDATE flow_task SET statusDONE, versionversion1 WHERE id? AND statusPENDING, [taskId] ); if (r.affectedRows 0) throw new BizError(TASK_HANDLED, 该任务已被处理); const task await tx.one(SELECT * FROM flow_task WHERE id?, [taskId]); // 2) 写审批记录只追加request_id 唯一索引挡住重复提交 await tx.query( INSERT INTO flow_record(instance_id,task_id,node_key,round,handler_id,action,comment,request_id) VALUES (?,?,?,?,?,?,?,?), [task.instance_id, taskId, task.node_key, task.round, userId, action, comment, requestId] ); // 3) 判定当前节点是否整体完成 const nodeState await judgeNode(tx, task.instance_id, task.node_key, task.round); if (!nodeState.done) return { instanceId: task.instance_id, nodeFinished: false }; // 4) 驳回按节点配置决定退回发起人还是退回上一节点 if (nodeState.rejected) return rollbackTo(tx, task, userId); // 5) 找下一节点没有下一节点说明流程走完 const next await findNextNode(tx, task.instance_id, task.node_key); if (!next) return finishInstance(tx, task.instance_id, APPROVED); // 6) 按审批人策略批量生成待办 return spawnTasks(tx, task.instance_id, next); }); }第 1 步的WHERE id? AND statusPENDING是整个函数的关键把“判断任务是否待办”和“把任务置为已办”合并成一条原子语句并发下只有一个请求能让affectedRows等于 1另一个直接走异常分支。这比先 select 再判断再 update 少一个竞态窗口也不用额外加分布式锁。第 3 步的judgeNode只看当前node_key和round下的全部任务不看历史轮次这是round字段存在的意义。第 5 步找下一节点时要重新读一遍定义里的edges因为分支条件可能依赖本次审批填写的表单变量这一条链路必须能拿到最新的流程变量。4.2 会签、或签、依次审批的判定差异三种审批类型的差别全在“节点什么时候算结束”这一件事上写成一个纯函数就够了便于单测。// 节点完成判定返回 done是否终结、rejected是否否决、passed是否通过 function judgeNodeByType(node, tasks) { const done tasks.filter(t t.status DONE); const rejected done.filter(t t.action REJECT); switch (node.approveType) { case ANY: // 或签一人处理完即终结同意即通过 return { done: done.length 0, rejected: rejected.length 0, passed: done.some(t t.action APPROVE) }; case ALL: { // 会签可配一票否决否则按 passRatio 算通过率 if (node.rejectStrategy ONE_VETO rejected.length 0) return { done: true, rejected: true, passed: false }; const ratio done.filter(t t.action APPROVE).length / tasks.length; const all done.length tasks.length; if (!all ratio (node.passRatio ?? 1)) return { done: false }; return { done: true, rejected: false, passed: ratio (node.passRatio ?? 1) }; } case SEQ: // 依次审批只有当前这个人处理完才生成下一个人 return { done: done.length 0, rejected: rejected.length 0, passed: done.some(t t.action APPROVE) }; default: throw new Error(未知审批类型: ${node.approveType}); } }参数与配置说明字段取值含义approveTypeANY / ALL / SEQ或签、会签、依次审批passRatio01默认 1会签通过阈值0.5 即过半rejectStrategyONE_VETO / NORMAL会签中一人否决是否直接终止节点assignee.typeUSER / ROLE / DEPT_LEADER / STARTER_CHOOSE审批人来源SEQ类型的实现要特别注意它的任务不是一次性全部生成而是生成第一条处理完再生成下一条。很多实现图省事一次性把 5 个人都插进任务表结果第一个人还没批后面四个人在待办里都看到了这条单据依次审批就退化成了会签。判断方式是看flow_task里同一node_key round的PENDING记录数SEQ类型永远应当只有 1 条。4.3 条件分支的表达式求值分支条件写在 JSON 里本质是配置数据绝不能拿去eval或new Function——那等于给配置开了一个执行任意代码的口子。我写了一个只认比较运算和逻辑运算的小求值器够用且可控。const OPS { : (a, b) a b, : (a, b) a b, : (a, b) a b, : (a, b) a b, : (a, b) a b, !: (a, b) a ! b }; // 只支持 A B || C 这种两层结构括号内可继续递归 function evalCondition(expr, vars) { return expr.split(||).some(part part.split().every(atom evalAtom(atom.trim(), vars)) ); } function evalAtom(atom, vars) { if (atom.startsWith(() atom.endsWith())) { return evalCondition(atom.slice(1, -1), vars); // 去掉外层括号递归 } const m atom.match(/^([\w.])\s*(|||!||)\s*(.)$/); if (!m) throw new Error(条件表达式不合法: ${atom}); const left getByPath(vars, m[1]); // 支持 form.days 这类取值 const right parseLiteral(m[3].trim()); // RD 转字符串5000 转数字 return OPS[m[2]](left, right); }三个要注意的地方。getByPath要支持form.days这种点路径但路径必须白名单校验不允许出现__proto__、constructor之类parseLiteral要区分引号包裹的字符串和裸数字否则dept 1和dept 1会得到相反结果这类问题排查起来非常费时这个求值器不支持跨层括号嵌套(A || B) C里的括号跨了遇到复杂条件建议在流程定义里拆成两条连线而不是把表达式写复杂。调用位置在findNextNode里取出当前节点的所有出边对带condition的边求值取第一条为真的多条同时为真时按定义顺序取第一条并打一条告警日志方便发现配置歧义。4.4 退回、撤销与加签这三个动作最容易写漏边界。退回要决定“退到哪”撤销要判断“还能不能撤”加签要区分“前加签”和“后加签”。// 退回默认退回上一已审批节点也支持退发起人、退指定节点 async function rollbackTo(tx, task, userId) { const node await getNodeDef(tx, task.instance_id, task.node_key); const target node.returnTo || PREV; // 1) 关闭当前节点剩余待办不能留着否则还会有人继续批 await tx.query( UPDATE flow_task SET statusCANCELED WHERE instance_id? AND round? AND statusPENDING, [task.instance_id, task.round] ); // 2) 实例回到 RUNNING轮次 1历史记录全部保留 await tx.query( UPDATE flow_instance SET stateRUNNING, roundround1 WHERE id?, [task.instance_id] ); // 3) 按 target 决定新任务落在哪个节点PREV 取上一节点STARTER 取 start const nodeKey target STARTER ? start : await prevNodeKey(tx, task); return spawnTasks(tx, task.instance_id, { nodeKey, isReturn: true }); }加签我一般做成“插一条task_typeADD_SIGN的任务node_key与当前节点相同”这样会签判定里它天然算一票不用额外改判定逻辑。前加签插在当前任务之前、当前任务置为等待后加签直接插一条新任务当前任务正常结束。撤销的判断条件是实例状态为RUNNING且第一节点尚未有人审批——只要有一个人批过撤销按钮就该禁用这是业务共识也是防止“批到一半被撤单”的必备规则。5. 常见翻车点排查五条最容易在上线后集中暴露的问题5.1 与数据一致性有关的三条一、两个标签页同时点同意流程被推进两次。现象是审批轨迹里同一节点出现两条完全相同的同意记录或者流程直接跳过了下一节点。原因很典型先SELECT出任务判断statusPENDING再UPDATE两步之间存在时间窗口两个请求都能通过判断。解决办法是把判断塞进UPDATE的WHERE里用affectedRows做裁决也就是 4.1 里那种写法-- 正确姿势状态判断和数据修改在同一条语句里完成 UPDATE flow_task SET statusDONE, versionversion1 WHERE id? AND statusPENDING; -- affectedRows 0 直接抛「该任务已被处理」不要再往下走二、网络超时用户重复点击记录表多出一条。现象是用户说“我就点了一次”但记录表里两条一模一样的记录节点判定被多算了一票。原因是前端按钮没有防抖、接口没有幂等键服务端把两次请求当成两次独立操作。解决分两层前端点击后立即置灰并生成requestId重试复用同一个 id服务端在flow_record上建(task_id, request_id)唯一索引插入冲突时捕获ER_DUP_ENTRY直接返回成功结果而不是报错给用户。三、会签最后一票并发节点状态卡在中间。现象是一个三人会签的节点三个人的任务都是DONE但实例还停在RUNNING谁也没收到结束通知。原因是三个请求同时进入事务各自判定“还有别人没批”于是谁都没有触发节点终结。解决办法是把节点完成判定放到事务内、并对实例行加锁SELECT ... FROM flow_instance WHERE id? FOR UPDATE保证同一实例的判定串行执行代价是并发度降低但审批场景下这个代价完全可以接受。5.2 与流程演进有关的两条四、改完流程定义在途单据的轨迹对不上。现象是测试环境好好的上线后改了一版流程已经发起的单据突然跳到了新节点或者报“节点不存在”。原因很简单实例没有绑定版本运行时每次都去取最新的 PUBLISHED 定义。解决方式就是 3.1 里的def_version实例创建时把版本号写死之后所有流转都按def_key def_version取定义。这样改版只影响新发起的单据在途单据按旧规则跑完历史轨迹永远自洽。五、退回重提后审批历史被覆盖。现象是退了再提时间线上只剩最后一轮前面谁批过、谁退的、什么意见全没了。原因是图省事复用了原来的任务行重新UPDATE成PENDING。解决办法有两条铁律任务行绝不复用退回时全部置CANCELED重提时插入全新任务记录表只追加不修改靠round区分轮次。查轨迹时按round分组展示两轮审批并列显示这比只显示最新一轮更有说服力答辩演示时也更直观。6. 验收与答辩用一张流程用例表把系统讲透功能点逐条点一遍不叫验收那叫演示。我给这套引擎准备的验收材料是一张用例表7 条覆盖全部关键路径和异常路径每跑通一条就打钩出问题直接定位到模块。编号用例关键验证点对应模块C1单级审批全通过实例终态为 APPROVED轨迹 2 条状态机C2金额 5000 走总监分支条件求值命中正确出边表达式求值C3三人会签两人同意一人驳回节点判定与rejectStrategy一致会签判定C4退回后重提round变为 2第一轮记录完整保留退回逻辑C5撤销已有人审批的单据接口返回 400实例状态不变状态机、权限C6双击审批按钮只有一条记录affectedRows只成功一次幂等C7改版后在途实例继续跑按旧版本定义流转不报节点不存在版本隔离答辩时的讲述顺序我建议反过来先讲状态机图再讲表结构最后演用例。因为评委最先质疑的永远是“状态会不会乱”你把白名单式的迁移表往那一放这个问题基本就结束了然后是“数据丢了怎么办”flow_record的 append-only 加round分组能直接回答最后演示 C4 和 C6 这两个异常用例比演示十个正常流程更能说明工程成熟度。还有一个小技巧把flow_record的时间线页面做成答辩 PPT 的第一张图一条单据从发起到三级审批、中途退回一次再重提整个链路一屏看完比任何架构描述都省口舌。这套方案我自己复盘过好几轮最早那版没有round、也没有request_id上线两周就被并发和退回两个问题教育了一遍加班改表结构的时候是真的后悔。从那以后我每次接审批相关的活儿都强制先把那张七行的用例表写出来再动手建表宁愿前期多花半天。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询