做企业信息化这行被问得最多的一类毕设题目就是“基于技术的工作流OA审批系统”。乍一看平平无奇不就是填个表单、点个同意、走个流程真上手写两周就知道了坑全在“流程”这两个字上。单条请假申请是标准的增删改查十条并行的报销单、带会签的采购审批、中途撤回再重提的合同流转立刻变成状态机、并发控制、动态路由的组合题。这也是为什么这个题目年年有人写又年年有人卡在核心模块跑不起来。这篇内容我按源码实现的顺序拆一遍数据模型怎么设计、工作流引擎怎么选、会签驳回这些硬骨头怎么啃、开题报告里的技术路线怎么写才不空。刚接触工作流的同学可以照着搭骨架已经写过一版但流程一跑就乱的也可以对着排查。1. 先想清楚OA审批系统的难点到底在哪1.1 审批不是增删改查状态机才是核心很多人第一次做审批系统本能反应是建一张申请表加一个status字段0 待审、1 通过、2 驳回然后在 Controller 里写几个 if 判断。这种写法在单人单级审批时能跑通一旦遇到“部门经理审完到财务财务审完到总监任何一级驳回都退回发起人”这种需求代码里立刻长出一堆嵌套判断改一次流程就要动一次业务代码。审批系统的本质是一个状态机加流转规则。每条申请单是一条流程实例实例在若干个节点之间流动每个节点产生一条或多条待办任务任务被处理后驱动实例往下一个节点走。把“单据数据”和“流转状态”拆开是做出可维护系统的第一步。业务表只存业务字段申请人、金额、事由、附件流程表存实例、任务、审批记录。两套表通过business_key关联业务代码不关心流程怎么走流程引擎不关心单据里填了什么。这么拆的好处很实际新增一种审批类型比如从请假扩展到采购业务侧只加一张业务表和一套表单配置流程侧复用同一套引擎不用重写流转逻辑。后面要做流程版本升级、审批时效统计、待办聚合也都在这套模型上自然延伸。注意业务表和流程表之间不要做外键约束。流程实例可能因为撤回、终止而产生孤儿数据外键会让清理和归档变得非常难受用business_key做逻辑关联就够了。1.2 自研引擎还是引入成熟引擎这道选择题要早做“基于技术的工作流”这个说法比较宽落到实现上其实就两条路引入 Flowable、Activiti 这类成熟引擎或者自己写一套轻量流转逻辑。两条路都能交付但适用场景差别很大。引入成熟引擎的优势是功能全并行网关、会签、子流程、边界事件这些开箱即用配套的表结构有几十张文档也齐全。代价是学习曲线陡光是理解ACT_RU_EXECUTION、ACT_RU_TASK这几张运行时表的关系就要花几天出问题时日志晦涩调试成本高。对于周期只有三四个月、还要写论文的毕设场景很容易出现“引擎跑起来了但说不清原理”的尴尬。自研轻量引擎的优势是完全可控表结构自己定代码每一行都能讲清楚答辩时被问到实现细节不会卡壳。代价是并行、会签、驳回这些复杂规则要自己实现边界情况得自己兜。我个人的建议是如果你的流程以串行审批为主最多加一个简单的条件分支自研完全够用而且更容易讲透如果需求里明确要求并行分支、子流程、定时任务这类能力再考虑引入成熟引擎并且在开题阶段就把“引擎选型对比”写进技术路线这本身就是加分项。对比维度自研轻量引擎成熟开源引擎表结构复杂度5 到 8 张核心表20 张以上串行审批开发量低中并行与会签需要自己实现开箱即用调试难度低逻辑全在手里中高需要读引擎源码论文可讲述性强能讲清每一步弱容易变成调用说明版本升级支持需要自己设计引擎内置选型没有绝对对错关键是想清楚你的流程复杂度上限在哪别为了用引擎而用引擎也别为了省事把复杂需求硬塞进串行模型里。2. 数据模型几张表撑起一条审批流2.1 流程定义与流程实例必须分离流程定义描述“这类审批该怎么走”流程实例描述“这一单现在走到哪了”。两者混在一张表里是新手最容易犯的错。定义是模板一单业务对应一个实例一个定义对应多个实例关系是一对多。流程定义表我一般这么设计CREATE TABLE wf_process_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_key VARCHAR(64) NOT NULL COMMENT 流程标识如 leave_apply, process_name VARCHAR(128) NOT NULL COMMENT 流程名称, version INT NOT NULL DEFAULT 1 COMMENT 版本号, form_key VARCHAR(64) NOT NULL COMMENT 绑定的表单标识, node_config TEXT NOT NULL COMMENT 节点配置JSON, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_key_version (process_key, version) );节点配置用 JSON 存是自研方案里比较省事的做法。一条请假流程的配置大概长这样{ nodes: [ {key: start, type: start, next: dept_leader}, {key: dept_leader, type: approve, name: 部门负责人, approverRule: DEPT_LEADER, next: hr}, {key: hr, type: approve, name: 人事审批, approverRule: ROLE:HR, condition: days 3, next: end}, {key: end, type: end} ] }把节点定义外置成配置而不是硬编码在 Java 类里改流程不用重新发版这是工作流系统最基本的设计意识。流程实例表则记录运行态CREATE TABLE wf_process_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, def_id BIGINT NOT NULL COMMENT 启动时绑定的定义ID, business_key VARCHAR(64) NOT NULL COMMENT 业务单据ID, initiator BIGINT NOT NULL COMMENT 发起人, current_node VARCHAR(64) COMMENT 当前节点key, status TINYINT NOT NULL DEFAULT 0 COMMENT 0运行中 1已完成 2已终止 3已撤回, start_time DATETIME NOT NULL, end_time DATETIME, KEY idx_business (business_key), KEY idx_initiator (initiator, status) );注意def_id存的是启动那一刻的具体版本不是process_key。这个细节决定了后面版本升级时存量单据还能不能正常流转第 5 节会展开讲。2.2 任务表与审批记录表承担了全部运行态任务表是待办列表的数据来源一条任务对应一个或多个处理人。单人审批时assignee直接填人会签或或签时处理人是多个这时候要么在任务表里用 JSON 存候选人列表要么单独建一张候选人表。我倾向后者查询和统计都方便。CREATE TABLE wf_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_key VARCHAR(64) NOT NULL, task_type VARCHAR(16) NOT NULL COMMENT SINGLE/ALL/ANY, assignee BIGINT COMMENT 单人任务的处理人, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待办 1已通过 2已驳回 3已跳过 4已转办, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁, create_time DATETIME NOT NULL, finish_time DATETIME, KEY idx_assignee_status (assignee, status), KEY idx_instance (instance_id) );version字段不是摆设。两个审批人同时点“同意”如果在代码里先查后改很容易两边都判定成功流程被推进两次。用UPDATE wf_task SET status1, versionversion1 WHERE id? AND status0看影响行数是不是 1就能把并发问题挡在数据库层。这个技巧在第 5 节还会细说。审批记录表是审计和追溯的依据任务表只保留“当前状态”历史动作全部落在记录表里CREATE TABLE wf_approve_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, task_id BIGINT, node_key VARCHAR(64) NOT NULL, operator BIGINT NOT NULL, action VARCHAR(16) NOT NULL COMMENT APPROVE/REJECT/TRANSFER/ADD_SIGN/WITHDRAW, opinion VARCHAR(512), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_instance (instance_id, create_time) );驳回、转办、加签、撤回全部记录在这张表里前端画审批时间线直接按时间正序渲染不需要额外加工。我在实际项目里发现这张表的action字段枚举一定要在代码里用常量类管住别让字符串散落在各处否则后面加一种“抄送”动作时要改十几个地方。2.3 组织架构与审批人绑定关系审批人怎么确定是这个系统里仅次于流转逻辑的第二大难点。常见规则有四种按发起人的直属上级、按部门负责人、按角色、按表单字段指定。把规则抽象成一个approverRule字符串解析时用策略模式分发扩展新规则只要加一个实现类。组织架构表至少要包含用户、部门、部门负责人、用户所属部门这几个信息。部门负责人我建议单独存字段而不是靠“角色等于经理且属于该部门”去反查因为一个部门可能有正副职反查逻辑会越写越复杂。规则类型配置示例解析逻辑直属上级LEADER查用户表的 leader_id部门负责人DEPT_LEADER查发起人所在部门的 leader_id指定角色ROLE:HR查角色关联用户可多人则转会签表单字段FIELD:approver直接取表单里选的用户提示解析出来的审批人如果为空不要静默跳过要抛业务异常并提示“未找到审批人请联系管理员配置”。静默跳过会让单据莫名其妙地直接通过这是线上事故的常见源头。3. 流程流转的核心实现3.1 串行、并行、会签、或签的规则拆解串行最好理解一个节点一个处理人审完找下一个节点。并行是同一个节点分出多条支路全部走完才汇聚。会签是同一个节点多个处理人所有人都同意才算通过。或签是同一个节点多个处理人任意一人处理即可。用生活化的类比串行是接力棒一棒传一棒并行是分头办事都办完再碰头会签是圆桌表决全票通过或签是值班窗口谁来办都算数。这四种模式在数据上的差别主要体现在任务表和实例状态判定上。串行和或签一个节点只产生一条有效任务或签可能产生多条但只认第一条处理结果会签要产生 N 条任务节点完成条件是“所有任务状态非待办且全部为通过”。判断节点是否完成我一般写一个统一的方法public boolean isNodeCompleted(Long instanceId, String nodeKey) { ListWfTask tasks taskMapper.selectByInstanceAndNode(instanceId, nodeKey); if (tasks.isEmpty()) return false; // 会签全部通过才算完成任意驳回则节点失败 boolean anyRejected tasks.stream() .anyMatch(t - t.getStatus() TaskStatus.REJECTED); if (anyRejected) return true; return tasks.stream().allMatch(t - t.getStatus() ! TaskStatus.PENDING); }这段逻辑看着简单但要注意“跳过”状态。会签里某个审批人被转办后原任务标记为跳过这时候不能因为存在跳过任务就认为节点没完成。状态枚举一定要提前想全不要等到写流转逻辑时才发现少了状态。3.2 条件分支与动态审批人的计算时机条件分支的典型场景是“请假天数大于 3 天才需要人事审批”。实现上有两种思路一种是在节点配置里写条件表达式流转时逐条求值另一种是在代码里写死判断。前者灵活后者简单。表达式求值我推荐用 SpEL 或者简单的自研解析器把表单数据和流程变量放进上下文求值结果决定走哪条出边。比如days 3 deptType TECH用 SpEL 一行就能算。自己写解析器的话至少支持比较运算和与或非够用就行。动态审批人的计算时机很关键。我的做法是在任务创建时解析而不是在流程启动时一次性解析完。原因是审批人可能中途离职、调岗启动时算好的人到节点时可能已经无效。任务创建时实时查组织架构拿到的是当下有效的人。这里有个细节要注意如果同一个节点解析出多个审批人要根据节点配置决定生成会签任务还是或签任务。默认我建议按会签处理宁可多审一轮也不要因为或签导致某个人被绕过。3.3 驳回、撤回、转办、加签的处理逻辑驳回是最容易写乱的动作。需求里常见的驳回目标有三种退回上一节点、退回发起人、退回指定节点。实现时要区分“驳回后原任务怎么处理”和“实例的当前节点怎么改”。退回到发起人本质是把实例状态重置为发起节点清空当前节点发起人重新提交后从第一个审批节点再走一遍。退回到上一节点要找到历史记录里最近一个已完成的节点重建该节点的任务。退回到指定节点则要按节点配置重新生成任务。public void reject(Long taskId, Long operator, String targetNode, String opinion) { WfTask task taskMapper.selectById(taskId); // 1. 校验操作人 checkOperator(task, operator); // 2. 乐观锁更新任务状态 int rows taskMapper.finishTask(taskId, TaskStatus.REJECTED); if (rows 0) throw new BizException(任务已被处理请刷新); // 3. 记录审批动作 recordMapper.insert(buildRecord(task, operator, Action.REJECT, opinion)); // 4. 重置实例到目标节点 instanceService.resetToNode(task.getInstanceId(), targetNode); }撤回和驳回不同撤回是发起人主动收回前提是第一个审批节点还没处理。实现时校验实例状态为运行中、且当前节点没有已完成的任务然后终止当前任务、把实例置为撤回状态。撤回后允许重新编辑再提交重新提交时复用同一个实例还是新建实例取决于业务要求。我一般选择复用实例保留完整的审批轨迹。转办是把任务的处理人换掉任务本身不变。加签是新增一条任务前加签表示新任务排在当前任务之前处理后加签表示当前任务处理完后再处理新任务。加签的实现要注意新增任务和原任务在会签判定里的关系别让节点完成条件被新任务卡住。注意所有流转动作都要先做权限校验再更新数据。顺序反了一旦校验失败数据已经被改了一半回滚很麻烦。校验逻辑包括操作人是不是任务处理人、任务状态是不是待办、实例状态是不是运行中。4. 权限、通知与前端交互4.1 菜单权限与数据权限要分层处理权限这块最容易做成“能登录就能看所有单子”的漏洞系统。我一般拆成三层菜单权限控制能看到哪些页面操作权限控制能点哪些按钮数据权限控制能看哪些数据。菜单和操作权限用角色关联权限码登录后把权限码列表返回给前端前端做按钮级控制后端在接口上做二次校验。数据权限更细普通员工只能看自己发起的单子部门负责人能看本部门的管理员能看全部。实现方式有两种在 SQL 里拼接数据范围条件或者在服务层做过滤。前者性能好后者灵活我倾向在 Mapper 里用动态 SQL 拼条件把数据范围参数传进去。select idselectTodoPage resultTypeTodoVO SELECT t.*, i.business_key, i.initiator FROM wf_task t JOIN wf_process_instance i ON t.instance_id i.id WHERE t.status 0 if testdataScope SELF AND t.assignee #{userId} /if if testdataScope DEPT AND i.initiator IN (SELECT id FROM sys_user WHERE dept_id #{deptId}) /if /select这样写的好处是权限逻辑集中在一处加新角色时不用到处改判断。4.2 待办已办的消息触达机制待办提醒做得好不好直接决定这个系统上线后有没有人用。基本的触达方式有三种站内消息、邮件、即时通讯工具推送。站内消息必须有邮件和即时通讯看场景。消息发送要异步化不要在审批流转的事务里同步发。任务创建成功后投一条消息事件用线程池或者消息队列消费发送失败不影响主流程。表结构上消息表记录接收人、消息类型、关联业务ID、已读状态前端下拉铃铛查未读数。Transactional public void createTask(WfTask task) { taskMapper.insert(task); // 事务提交后再发消息 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { messageService.sendTodo(task); } }); }用afterCommit而不是直接在事务里发是为了避免消息发出去了但事务回滚的情况。这个小技巧我在实际项目里踩过坑早期直接在事务里发邮件结果偶发数据库死锁回滚用户收到一封点进去查无此单的邮件投诉了好几次。4.3 审批页面的动态表单渲染表单要和流程解耦不然每加一种审批就要改一次前端。动态表单的思路是把表单定义存成 JSON字段包含类型、标签、校验规则、是否必填、数据来源。前端用渲染器根据配置生成表单后端用统一的接口存业务数据。{ formKey: leave_apply, fields: [ {key: leaveType, label: 请假类型, type: select, options: [{value: 1, label: 事假}, {value: 2, label: 年假}], required: true}, {key: days, label: 请假天数, type: number, required: true}, {key: reason, label: 事由, type: textarea, maxLength: 500} ] }这样设计之后业务数据存成 JSON 或者宽表都能接受。JSON 的优点是加字段不用改表结构缺点是查询统计不方便宽表的优点是便于报表缺点是每加一种审批就要建表。我的折中方案是审批数据存 JSON需要统计的字段金额、天数、类型单独抽出来存索引表兼顾灵活和查询。5. 部署与压测中的实际问题排查5.1 并发审批导致状态错乱这是实测中最容易复现的问题。两个审批人同时打开同一条待办其中一个先点了同意另一个再点如果不加控制第二次点击会再次推进流程导致流程跳过节点或者重复创建任务。排查思路很简单看审批记录表是不是出现了同一节点两条同意记录看任务表是不是有任务被改了两次。解决手段就是前面提到的乐观锁更新语句带上status 0的条件判断影响行数。UPDATE wf_task SET status 1, finish_time NOW(), version version 1 WHERE id #{taskId} AND status 0 AND version #{version}影响行数为 0 时返回“任务已被处理”前端提示刷新。别用SELECT ... FOR UPDATE做行锁并发量不大时能用但会拖慢整个审批操作而且容易和别的锁产生死锁。5.2 流程版本升级后的存量实例兼容流程改版是常态比如原来部门经理审完就结束现在要加一级总监审批。新提交的单子用新版本没问题已经在跑的旧单子怎么办关键点在第 2 节提到的实例绑定的def_id存具体版本不是process_key。流转时按实例的def_id去查节点配置旧单子继续走旧配置直到结束新单子启动时查最新版本。这样升级不会打断存量业务。如果业务要求旧单子也必须走新流程那就要做流程迁移把运行中实例的def_id指向新版本并且校验当前节点在新版本里是否还存在。这个操作风险高建议在低峰期做并且提前备份。5.3 常见问题速查表现象可能原因排查方向待办列表为空但流程在跑任务处理人解析为空或错误查任务表 assignee 字段核对组织架构点了同意流程不动节点完成条件判定有误查该节点所有任务状态确认会签是否全完成流程重复流转缺少并发控制查审批记录是否同节点多条加乐观锁驳回后找不到单子实例状态被置为终止核对驳回逻辑里的状态字段消息收不到异步发送失败未重试查消息表状态和发送日志旧单子报节点不存在版本升级未绑定具体 def_id核对实例的 def_id 是否指向正确版本除表格里的问题还有两个隐性坑值得说。一是时间字段统一用数据库时间而不是应用服务器时间多实例部署时服务器时间不一致会导致排序错乱。二是删除流程定义不要让物理删除改成停用状态否则历史实例关联不上定义审批时间线渲染会报错。6. 开题报告怎么写才不显得空6.1 研究内容和技术路线要能对上号开题报告最常见的毛病是研究内容写得宏大技术路线写得笼统答辩老师一看就知道是拼凑的。正确的写法是研究内容列三四条具体的每条对应技术路线里的一个模块。比如研究内容写成“工作流引擎的轻量化设计与实现”“动态审批人规则引擎的设计”“审批数据统计分析模块的实现”技术路线就对应画出流程定义管理、任务流转、规则解析、报表统计几个模块说明每个模块用什么技术、解决什么问题。别写“基于 Spring Boot 框架开发”这种谁都会写的话要写“流程定义采用 JSON 配置外置避免流程变更导致的代码重新发布”。6.2 创新点宁少勿假创新点是开题报告的重灾区很多同学写“首次将工作流技术应用于 OA 审批”这种话一看就是凑字数。实在的创新点可以从这几个角度找审批人规则的可配置化、流程版本升级时存量实例的兼容方案、审批时效的数据分析与预警、动态表单与流程引擎的解耦设计。这些点不一定多高深但要能说清楚“原来怎么做、我的做法解决了什么问题”。比如版本兼容原来做法是升级后旧单子报错你的方案是实例绑定具体版本旧单子按旧版本走完。这种具体的对比比空喊创新有说服力得多。6.3 源码交付和论文的边界要划清源码是源码论文是论文两者不能互相替代。论文里要讲的是设计决策和实现原理不是贴代码。我见过把 Controller 代码整段贴进论文的查重都过不了。建议的篇幅分配需求分析占一章系统设计占一章重点画 ER 图和模块图讲清楚表为什么这么设计核心功能实现占一章挑流转逻辑、审批人解析、并发控制三个点展开测试与总结占一章。源码作为附件交付论文里只放关键片段的伪代码和流程图。论文里讲实现的时候多写“为什么这么选”少写“怎么调用接口”。比如讲工作流引擎选型把自研和引入引擎的对比表放进去说明你的场景为什么选自研讲并发控制把乐观锁的 SQL 和影响行数判断写清楚说明为什么不用悲观锁。这些内容既有技术含量又不容易和别人重复。最后再分享一点个人体会。做这类系统真正花时间的不是写代码而是把流程的各种边界情况想全。我在实际项目里习惯在动手前先画一张状态流转图把所有状态和允许的动作列成矩阵哪个状态能执行哪个动作、执行后变成什么状态一行一行填。这张表填完代码基本就是照着翻译返工率会低很多。另外源码里的注释别省特别是流转相关的判断过两周回头看自己都未必记得当时为什么这么写。