
今年以来一直有读者私信问我像“看潮”这类偏传统行业的管理软件项目核心难点到底在哪。我的答案一直很固定不在界面多华丽也不在报表多花哨而在单据审批这一层。做过企业软件的人都明白采购单、报销单、付款单、销售出库单几乎所有业务动作最终都要落到审批这个环节上。单据审批6-3听起来像某个系列课程的编号其实对应的是整个审批模块里最容易被低估、也最容易被做烂的两件事一是审批流的抽象二是审批动作与状态机之间的咬合。这篇就把我在这部分项目里的完整设计和落地过程拆开讲清楚。1. 内容整体设计与思路拆解1.1 “单据审批”到底在审批什么企业管理软件里的单据本质上是业务事实的载体。采购申请单表达“想买什么、花多少钱、谁来买”费用报销单表达“替公司垫了钱、需要还我、有没有票”销售出库单表达“货要发出去了、库存要扣减、应收要增加”。单据审批做的事情就是对这些业务事实做一道“闸门”决定它能不能进入下一个业务环节。在“看潮”项目里单据类型很多但审批环节不能每张单据单独写一套判断逻辑。否则每新增一种单据就得复制粘贴一遍审批代码后续光是维护审批状态和权限就能把人拖垮。到单据审批6-3我核心解决的思路就一个把所有单据的审批流程抽象成“一条链”链上的每个节点只关心三件事——谁来审、审什么条件、审完往哪走。当初给这套模块做方案选型时我对比过两种做法。第一种是使用现成的开源工作流引擎比如Flowable或者Activiti。这类引擎功能很强大支持复杂的会签、或签、条件分支但弊端也明显学习成本高、部署重、和业务表耦合时需要写大量适配代码。第二种就是自研一个轻量级的审批状态机把核心逻辑控制在几张小表和一个统一的调度类里通过配置化的方式驱动不同单据的审批路径。考虑到“看潮”的定位是中小企业的内部管理系统审批路径虽然多种多样但整体复杂度可控不需要上重量级流程引擎最终我选了第二种方案。1.2 审批状态机的核心设计思路状态机这个说法听起来学术实际上可以类比成电梯的运行逻辑。电梯不可能从1楼直接跳到10楼必须先经过2楼、3楼同样单据的审批也必须一步步从“草稿”走到“审批中”再到“通过”或“驳回”。任何跳级的状态变化在业务上都是灾难。所以我把所有单据的审批状态统一归纳成五个核心状态草稿DRAFT、审批中PENDING、已通过APPROVED、已驳回REJECTED、已撤回WITHDRAWN。所有单据类型的状态变化都只能在这五个状态之间迁移不允许自定义新的状态。这样做的直接好处是前端列表页的筛选、统计页的图表、以及报表模块的数据透视都只需要基于一套状态枚举做处理不需要针对每张单据写特殊逻辑。当然光有状态还不够。审批过程中还有两个关键信息需要沉淀下来当前的审批节点信息和审批历史。当前节点决定了“现在轮到谁审”审批历史则需要完整记录“谁在什么时间做了什么决定、批注了什么内容”。这两块数据在后续做待办中心和时间追溯时都要高频使用因此在设计数据表时就要提前预留好。2. 审批流的数据结构与核心表设计2.1 审批实例表与审批节点表这里我先说结论审批流相关的表不宜设计得太复杂三张核心表足够支撑90%以上的企业审批场景。第一张审批实例表approval_instance用来记录某张单据的审批整体信息第二张审批节点表approval_node记录审批链上的每个节点配置第三张审批记录表approval_record记录每一次审批动作。审批实例表的核心字段大概长这样业务单据类型、业务单据ID、当前节点ID、审批状态、发起人ID、发起时间、结束时间。这里有一个非常关键的细节业务单据类型和业务单据ID是联合使用的不能只存单据ID因为不同单据类型之间ID可能重复只存ID会导致后续查询审批状态时串数据。审批节点表的设计就要稍微动点脑筋了。每个节点需要记录节点顺序、节点名称、审批人类型角色、审批人ID、条件表达式和跳转规则。这个条件表达式在第一章提到过是决定审批链路是否需要走分支的关键字段。拿一个最常见的采购申请单举例。金额小于5000元的只需要部门经理审批金额在5000到50000之间的需要部门经理审批后再走财务经理超过50000的还要追加总经理审批。这种情况下我就可以在节点表里给第一个节点加一个条件表达式指向单据的金额字段然后根据表达式判断下一步走向哪个节点。2.2 用JSON字段存储节点链路的优势在做节点表设计时很多人会习惯性地把所有可能的分支逻辑用多张关联表来表达比如一张审批条件表、一张审批跳转规则表。但在实际项目里绝大部分单据的审批链路并不会频繁变更真正变更频繁的是审批人和审批条件。所以我在节点表里增加了一个node_config的JSON字段用来存放该节点的所有扩展配置。JSON字段的好处是灵活。比如某个节点需要“二级部门经理会签”传统的关联表设计需要在条件表和跳转表里各插好几条记录而JSON字段只需要在node_config里写一个会签配置对象代码解析时统一处理即可。另一个好处是当前端需要展示审批流程图时后端可以直接把这个JSON字段返回给前端渲染不需要再动态组装数据。但是这里也必须提醒一下JSON字段用得好是利器用得不好就是灾难。如果把条件表达式也全塞进JSON里后续做审批链路的条件分析和报表统计时SQL查询会变得很痛苦。所以我做了一个折中高频使用的检索字段当前节点、审批状态、审批人等独立建列低频使用的扩展配置统一放JSON。这个设计在后续开发中帮我省了很多事。2.3 审批记录表每一次操作都留痕审批记录表是最不该省的一张表。每个审批节点被处理时都会在这个表里新增一条记录字段包括审批节点ID、审批人ID、审批动作通过/驳回/撤回、审批意见、操作时间。这不是简单的操作日志因为审批记录承担着两方面的价值。其一它是业务审计的凭证。企业做内审或者外部审计时重点关注的就是审批环节是否合规某个关键节点是谁审批的、批注了什么、为什么被驳回。如果审批记录不完整或者被覆盖这套软件的可信度就大打折扣。其二它是前端时间线组件的数据来源。我在“看潮”的单据详情页里做了一个审批进度时间线从提交申请到最终审批完成每一步都在时间线上展示。这个时间线的数据就直接查询审批记录表简单高效不需要额外再建一张操作日志表。3. 审批流的状态机与核心逻辑实现3.1 从草稿到审批中提交动作的秘密单据刚创建时状态是草稿。此时用户可以任意编辑修改甚至删掉重做因为还没有进入正式的审批通道。真正让单据“活”起来的是提交动作。提交之后单据状态从草稿变为审批中同时系统会按照配置初始化审批实例和第一个审批节点。这个阶段有一个小细节需要注意提交动作必须做校验确认当前单据数据完整。比如采购申请单至少要有一个供应商、一条明细数据、一个总金额否则审批人拿到的是一张空壳单据业务上完全没法审批。我在代码里做了一个统一的提交校验入口不同类型的单据只需要实现同一个接口各自的校验逻辑放在各自实现类里。提交完成之后还有一件事必须做给第一审批人生成待办消息。注意这里说的是待办消息不是站内信也不是邮件而是一条结构化数据专门存放在待办任务表里。待办任务表的作用是支撑首页工作台和待办中心的列表展示确保审批人登录系统第一眼就能看到自己需要处理的单据。3.2 通过、驳回、撤回的边界条件审批中状态下可执行的动作有三种通过、驳回、撤回。其中撤回是最容易出bug的环节。业务规则是当前审批节点尚未被处理时发起人可以撤回一旦当前节点已经处于“待审批”状态但审批人还未操作理论上是可以撤回的。但如果审批人已经提交了审批意见也就是审批记录表里已经有了当前节点的处理记录发起人的撤回动作就必须被拦截。这个判断逻辑我最初写得比较粗糙只判断了单据状态是否是审批中结果出了几次生产事故——审批人刚点了通过发起人这边又点了撤回导致状态跳变异常。后续我把撤回的校验条件改成了撤回动作必须满足“当前节点没有处理记录”且“单据状态为审批中”两个条件同时成立才允许执行。这一步虽然看着简单却是审批模块稳定运行的重要保障。驳回的逻辑相对简单但有一个地方容易有歧义。审批人驳回了单据状态变成已驳回这时申请人修改完是可以重新提交的。重新提交后状态再次变为审批中而且审批流程需要从第一个节点重新开始走而不是接着之前驳回的节点走。这个规则我参考了市面上成熟的企业软件统一成“驳回之后重新提交走全新流程”避免出现同一张单据同时存在多个审批分支的老大难问题。3.3 审批动作的并发控制并发控制是审批模块的隐形大敌。设想一个场景采购申请单到了财务经理这个节点财务经理正在审批另一个审批管理员在后台强制转交节点这两个操作如果同时发生就会出现数据不一致的问题。轻则审批状态显示错乱重则单据卡在某个节点永远无法流动。我在设计这套审批状态机时对更新操作统一使用了乐观锁。审批实例表里增加了一个version字段每次更新状态时都检查当前版本号是否和最初读到的版本号一致一致才能更新成功并把版本号加一。如果两次更新同时触发后提交的那个请求会更新失败代码里再针对更新失败的情况做一次状态刷新和友好提示。这套方案简单并且稳定不太需要引入复杂的分布式锁。4. 审批动作与权限校验的实际编码4.1 审批人权限怎么校验才算严谨审批节点配置表里有审批人ID和审批人角色两个字段。业务规则是节点配置了具体审批人ID时只有该用户可以操作节点只配置了角色时拥有该角色的所有用户都能操作。但是在实际项目里有过一个坑审批人离职后他的账号被禁用而历史单据的待办还在他名下导致整个审批流卡死。针对这个场景我加了一个审批人解析策略。在生成待办任务时如果节点的审批人ID对应的用户已经被禁用系统会自动按照该节点配置的备选审批人或者该角色下的其他活跃用户进行顺延同时记录一条顺延日志到审批记录表里。这样即使审批人因各种原因无法处理也不会把整个业务流程堵死。除了审批人校验操作权限校验也是不能漏的。后端在接收审批请求时必须同时校验三方面信息第一当前登录用户是否为当前节点的合法审批人第二当前单据状态是否允许执行这个动作第三当前节点是否有未完成的处理记录。这三个校验缺一不可单纯靠前端按钮显隐来控制操作权限是很危险的因为前端按钮显隐可以通过抓包绕过。4.2 审批意见与附件上传的处理审批人通过或者驳回单据时大部分企业场景会要求填写审批意见。意见字段我会单独存储不拼接到审批动作字符串里。这是因为后续做审计查询时需要单独按照意见内容检索拼接存储会导致查询效率大幅下降。附件上传则提供了另一个维度的能力。审批人可以在节点上上传附件比如付款申请单的合同扫描件、报销单的电子发票文件等。附件关联的表结构也做成了通用型用业务类型加业务ID的方式关联不管是审批节点附件还是单据主表附件都能统一管理。文件存储部分没有用服务器本地磁盘因为企业软件大概率会做多实例部署本地文件在多实例环境下会互相看不到所以统一走对象存储服务。4.3 审批完成之后的回调通知审批链走到最后一个节点并且审批动作是通过时系统要做的事情远远不止改状态。以付款单为例审批通过之后需要生成付款记账凭证需要更新商家的应付余额甚至需要触发短信通知到出纳人员。如果这些操作都放在审批通过的同步逻辑里一旦某个下游步骤异常整个审批动作就会失败甚至可能导致审批已经通过了但业务实际没有联动。我采用的方案是事件发布机制。审批通过后状态机只负责发送一个“审批已完成”的领域事件真正的业务联动逻辑监听到事件后异步执行。异步执行的失败会记录到消息表里并且提供补偿重试机制。这个过程让审批主流程保持简洁同时保证了业务联动不丢不漏。5. 常见问题与排查技巧实录5.1 单据状态还在“审批中”但流程已经卡住这是企业软件上线后最常遇到的工单。排查的第一步永远不是看代码而是直接查审批实例表和审批记录表。先看当前节点ID是多少再看这个节点在审批记录表里有没有处理记录。如果处理记录是空的说明审批人的待办任务丢失了可以尝试重新生成待办任务。如果处理记录存在但是单据状态没变那就要考虑是不是状态更新的事务提交出问题了。早期我遇到过一类情况审批记录和单据状态更新放在了两个事务里记录提交了状态更新却因为异常回滚了。排查到这类问题后我统一把审批动作的全部操作收拢到同一个事务边界内同时使用事务消息来解决异步通知的问题最终才彻底解决。5.2 审批完第一节点后第二个节点的待办人不对这个问题往往出在节点配置环节。企业客户的审批人会频繁调整比如某个部门经理换人了但配置后台里的节点信息没有同步更新。我后来在审批节点解析时增加了一个逻辑每次生成待办任务前都实时读取最新的人员配置而不是使用审批实例创建时的快照。这个改动虽然会增加一点查询压力但能够大幅度减少人员变动带来的单据卡顿问题。还有一个关联问题是会签节点的处理。所谓会签就是多个审批人都需要同意节点才算通过。我在实现会签时节点表里的审批人ID存储为一个JSON数组每次有人通过会签系统判断该节点所有审批人是否都已处理完毕都处理完才能进入下一个节点。这个场景下需要特别注意并发问题两个审批人同时点通过如果不用乐观锁或者去重判断就会导致节点被重复推进。5.3 维护审批流的最后一点建议给所有正在做或者准备做审批模块的朋友一个建议不要把审批流设计得过度通用。过度通用的代价是抽象层级太多代码里满是泛型和策略类业务人员根本看不懂后面接手的开发者更是叫苦不迭。在“看潮”项目的演进过程中我做过的最正确的决定就是明确定义了核心的状态机枚举和审批动作常量然后把所有灵活多变的策略都放在了可配置的节点表里。这样一来核心代码永远稳定业务的变化交给配置层去消化。哪怕后续要新增一种审批单据类型也只需要新加一个单据枚举值再配置一套审批节点即可完全不用改动审批引擎的代码。这点看起来微不足道但在经历了三个大版本的迭代之后我越来越确信好的软件设计不是代码写得有多炫而是业务变化的时候你不需要通宵改代码。