资讯动态

JS工作流与审批流系统设计:JSON流程定义、Node引擎与五表模型

发布时间:2026/10/29 18:11:18 来源:尧图企业网站定制
简介面向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 都行后端 NodeExpress 或 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 加唯一索引三层流程改了老单子怎么办——答实例锁版本号。这三个问题答得利索基本就稳了。从那以后我每次做流程类项目第一件事就是把五张表先画出来把定义和实例的边界定死再动一行代码验收清单也一定在开发前写不是开发完补。这套习惯救过我很多次。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑