ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于自研轻量流程引擎的OA系统源码:审批流程可视化配置与落地实践

基于自研轻量流程引擎的OA系统源码:审批流程可视化配置与落地实践

简介:这套OA办公自动化源码面向需要自建或二次开发审批流程的开发者与技术团队,提供一套可运行、可改造的完整实现,涵盖请假、报销等日常审批业务的流程设计与权限配置思路。压缩包约43.54MB,共近2000个文件,以gif图片、C#源码(.cs)、aspx页面为主,辅以js脚本、css样式、dll程序集与doc说明文档,另有csproj、sln工程文件。目录按典型三层架构拆分:Web与MobileWeb分别对应桌面端和移动端界面,TMOA.BLL承载审批流转与业务规则,TMOA.Common提供公共工具类,TMOA.DBUtility负责数据库访问,DB_51aspx存放示例数据。目前已有6933人学习。读者可据此理清OA系统的模块划分与分层调用关系,掌握审批流程的编码方式,并在其基础上替换业务字段、扩展流程节点,搭建贴合自身管理需求的办公平台。

1. 需求切入:审批流程一变就得改代码,这套 OA 源码要解决的就是这件事

某公司的行政专员每个季度都要调整一次报销审批链路——部门经理换了、金额阈值调了、新增了财务复核节点,每次都得提需求给开发,改代码、走测试、等发版,一来一回两周。一年下来光审批流程变更就压了十几张工单。这类需求在办公自动化场景里非常典型:业务规则天天变,但代码写死了。一套完整的 OA 系统源码,真正的价值不在于功能清单有多长,而在于审批流程能不能由业务人员自己画、自己发布、自己验证。这套源码围绕「审批流程可视化配置」展开,适合正在做 OA 毕设或课程设计的学生,也适合要给中小团队交付一套可维护办公系统的开发者。下面从架构讲到实现,再到怎么验收。

2. 总体架构设计:分层结构与流程引擎的职责边界

在动手写第一行代码之前,有一件事必须先确定:审批流程的「可变部分」和「不变部分」到底怎么切。如果流程引擎和业务代码混在一起,那所谓「自己设计审批流程」就只是一句口号,最后还是会退化成每加一个审批节点就动一次 Java 代码。这一章先把分层和边界定清楚,后面所有模块都围绕它展开。

2.1 技术选型:为什么用轻量自研引擎而不是直接上 Flowable

先看一张对比表,把两条路的取舍摆到台面上。

维度自研轻量流程引擎Flowable / Activiti
学习成本只需要理解自己定义的 JSON 模型需要掌握 BPMN 2.0 规范与引擎 API
源码可控性全部逻辑可见,改一行就生效引擎内部黑盒较多,出问题排查链路长
二次开发与业务表直接关联,扩展方便需要理解引擎表结构,扩展有耦合
适用场景中小团队 OA、毕设课设、流程相对固定大型企业、复杂并行网关、跨系统编排
演示效果能讲清每一步怎么流转容易变成「调 API」,讲不出设计

对一套要交作业、要交付给中小团队的 OA 系统来说,自研轻量引擎的优势非常明显:所有状态都在自己的数据库里,任何一个审批动作都能顺着表查下去,答辩时能把「为什么这么设计」讲透。Flowable 不是不好,而是它的复杂度在中小 OA 里属于过度设计,玄学问题一多反而压不住。

技术栈上,前端用 Vue 3 + Element Plus,流程设计器基于画布组件自行封装;后端 Spring Boot 2.7 + MyBatis-Plus,数据库 MySQL 8,缓存用 Redis 存待办计数和会话。整体分四层:

  • 接入层:Controller 做参数校验与权限拦截,不写业务逻辑;
  • 应用层:Service 编排流程发布、任务流转、消息通知;
  • 领域层:流程定义、流程实例、任务三个聚合根,各自有独立的状态机;
  • 基础设施层:Mapper、Redis、站内信通道。

这个分层的价值在于,流程引擎只依赖领域层,换数据库、换前端框架都不影响核心流转逻辑。

2.2 模块划分与核心数据表设计

按职责把系统拆成七个模块,每个模块对应一组表:

  1. 认证权限:用户、部门、角色、菜单权限;
  2. 表单中心:表单模板的增删改查与版本管理;
  3. 流程定义中心:流程建模、校验、发布、停用;
  4. 流程运行中心:实例启动、节点流转、任务创建;
  5. 待办中心:我的待办、已办、我发起的;
  6. 通知中心:站内信、邮件(可选);
  7. 审计日志:所有审批动作留痕。

真正撑起「可自定义」的是下面这几张表,字段设计直接决定后面代码好不好写。

表名关键字段作用
oa_form_templateform_key, name, schema_json, version, status表单模板,schema_json 存字段定义
oa_process_defprocess_key, name, version, definition_json, form_key, status流程定义,definition_json 存整棵节点树
oa_process_instanceprocess_def_id, business_key, initiator, status, current_node流程实例,一条业务单据对应一条
oa_taskinstance_id, node_key, assignee, assignee_type, status待办任务,会签时一个节点多条
oa_task_recordtask_id, operator, action, comment审批记录,用于追溯和退回
oa_approver_rulerule_key, rule_type, rule_config审批人解析规则,可复用

建表时有两个细节容易忽略。第一,definition_json用 JSON 类型存储而不是拆成多张节点表,因为流程定义一旦发布就不应该再改,拆表反而增加联表成本;第二,oa_task里必须冗余node_name,否则历史待办在流程定义升级后会显示错乱。核心建表语句大致如下:

CREATE TABLE oa_process_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_key VARCHAR(64) NOT NULL COMMENT '流程标识,如 expense_apply', name VARCHAR(128) NOT NULL COMMENT '流程名称', version INT NOT NULL DEFAULT 1 COMMENT '版本号,发布一次加一', definition_json JSON NOT NULL COMMENT '节点树与连线', form_key VARCHAR(64) NOT NULL COMMENT '绑定的表单模板', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布 2已停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_key_version (process_key, version) ) COMMENT '流程定义表';

process_key + version做唯一索引,是为了保证同一流程的版本不重复,实例启动时锁定具体版本,后续流程定义再怎么改都不影响在途单据。这一点在答辩时经常被问到,属于必须讲清楚的设计点。

3. 审批流程建模:节点、条件分支与审批人规则

架构定完,接下来是整套系统最核心的部分——怎么用一份数据把「谁在什么条件下审什么」描述清楚。建模做得好,后面代码就是按图索骥;建模做不好,条件分支和审批人规则会缠成一团,改一处崩三处。

3.1 流程定义模型:用一棵 JSON 树描述整条审批链

节点类型只保留五种,够用且不易失控:start(发起)、approval(审批)、condition(条件分支)、cc(抄送)、end(结束)。每个节点带nodeKey、nodeName、type,审批节点额外带approverRule。节点之间用nextNodeKey串联,条件节点用branches数组表达多路。

{ "processKey": "expense_apply", "nodes": [ { "nodeKey": "start", "type": "start", "nextNodeKey": "dept_leader" }, { "nodeKey": "dept_leader", "type": "approval", "nodeName": "部门经理审批", "approverRule": { "type": "DEPT_LEADER" }, "nextNodeKey": "amount_condition" }, { "nodeKey": "amount_condition", "type": "condition", "branches": [ { "expression": "${amount > 5000}", "nextNodeKey": "finance" }, { "expression": "default", "nextNodeKey": "end" } ] }, { "nodeKey": "finance", "type": "approval", "nodeName": "财务复核", "approverRule": { "type": "ROLE", "roleKey": "finance" }, "nextNodeKey": "end" }, { "nodeKey": "end", "type": "end" } ] }

这份 JSON 有几个设计取舍值得说。第一,用nextNodeKey单向链表而不是邻接矩阵,是因为 OA 流程绝大多数是串行加少量分支,链表足够表达且渲染简单;第二,条件节点的expression用${}包裹,解析时只支持字段比较和逻辑运算,不引入完整表达式引擎,避免执行任意代码的安全问题;第三,default分支必须存在,防止条件全部不满足时流程卡死。参数上,amount这类变量来自表单数据,启动实例时会把表单字段平铺进上下文,条件判断直接读上下文。

3.2 条件分支与审批人解析:让「谁来审」变成可配置规则

审批人规则是整个建模里最容易被低估的部分。很多毕设做到这里就写死「部门经理」,结果一演示就露馅。合格的规则至少要覆盖五类:

  • USER:指定具体用户,适合法务、财务等固定岗位;
  • ROLE:按角色匹配,一个角色多人时进入候选池;
  • DEPT_LEADER:取发起人所在部门的负责人;
  • FORM_FIELD:审批人由表单里选的人决定,适合「指定证明人」场景;
  • INITIATOR_SELECT:发起时自己选,适合临时加签。

解析逻辑放在独立的ApproverResolver里,每种类型对应一个策略实现,新增类型只需要加一个实现类,不改主流程。下面是一段解析核心代码:

public interface ApproverResolver { String getType(); List<Long> resolve(ApproverRule rule, ProcessContext ctx); } @Component public class DeptLeaderResolver implements ApproverResolver { @Override public String getType() { return "DEPT_LEADER"; } @Override public List<Long> resolve(ApproverRule rule, ProcessContext ctx) { // 从上下文拿到发起人,再查其部门负责人 Long initiator = ctx.getInitiator(); Long deptId = userMapper.selectDeptIdByUserId(initiator); Long leaderId = deptMapper.selectLeaderByDeptId(deptId); if (leaderId == null) { throw new BizException("部门未配置负责人,流程无法流转"); } return Collections.singletonList(leaderId); } }

ProcessContext里带着发起人、表单数据、当前实例,解析器只读不写,保证幂等。这里有个血泪经验:部门负责人为空时必须显式抛异常,而不是返回空列表让流程静默卡住——前者在待办页能看到错误提示,后者只能靠人肉查库,排查成本差十倍。

3.3 表单与流程解耦:字段驱动流转,而不是流程写死字段

表单和流程必须解耦。做法是表单模板独立维护,流程定义只存一个form_key,启动实例时按 key 取当前启用版本的表单 schema,把用户填的值存进oa_process_instance.form_data(JSON 字段)。这样同一套「报销流程」可以绑定不同表单,同一张表单也能被多条流程复用。

解耦带来的直接好处是条件表达式可以统一从form_data取值。比如${amount > 5000}中的amount就是表单里的字段名,建模时在设计器里下拉选择字段,避免手写字段名拼错。字段类型也要约束:只有数字和日期类型允许参与大小比较,字符串只支持等值判断,枚举走in判断。这些约束写进流程发布的校验器里,发布前就拦住非法表达式,比运行时报错友好得多。

4. 落地实现:从流程发布到任务流转的前后端关键代码

建模是纸面设计,这一章把它变成能跑的系统。顺序上先讲后端发布与流转,再讲前端设计器,最后补会签、或签、退回这些容易被跳过的细节。每一段代码都给关键注释,方便直接对照改。

4.1 流程定义发布:校验、存版本、生成节点快照

发布接口做三件事:校验 JSON 合法性、写入新版本、把节点树展开成快照缓存。校验包括节点 key 唯一、nextNodeKey必须存在、条件节点必须有 default 分支、审批节点的审批人规则必须能解析出至少一种策略。

@Transactional(rollbackFor = Exception.class) public Long publish(String processKey, String definitionJson, String formKey) { // 1. 解析并校验节点树 ProcessDefinition def = JSON.parseObject(definitionJson, ProcessDefinition.class); validator.validate(def); // 节点唯一性、连线完整性、规则可解析性 // 2. 取当前最大版本号,加一后落库 Integer maxVersion = processDefMapper.selectMaxVersion(processKey); int newVersion = (maxVersion == null ? 0 : maxVersion) + 1; ProcessDefPO po = new ProcessDefPO(); po.setProcessKey(processKey); po.setVersion(newVersion); po.setDefinitionJson(definitionJson); po.setFormKey(formKey); po.setStatus(1); processDefMapper.insert(po); // 3. 旧版本置为停用,但保留在途实例继续使用 processDefMapper.disableOtherVersions(processKey, newVersion); return po.getId(); }

关键点在第三步:旧版本不能删,因为在途实例还引用着它。停用只影响新发起的单据,这是版本管理里最容易翻车的地方,很多人图省事直接覆盖,结果在途单据流转到一半找不到节点定义。参数上,processKey在整个系统内唯一,version自增,status用 0/1/2 三态而不是布尔值,是为了给「草稿」留位置——设计器保存但不发布时走 0 状态。

4.2 任务流转核心:下一节点计算与审批人解析

审批动作的入口只有一个:complete(taskId, action, comment)。它要根据当前节点算出下一节点,解析审批人,生成新任务或结束实例。核心逻辑拆成三步,下面这段是主干:

@Transactional(rollbackFor = Exception.class) public void complete(Long taskId, String action, String comment) { OaTask task = taskMapper.selectById(taskId); ProcessInstance inst = instanceMapper.selectById(task.getInstanceId()); ProcessDefinition def = definitionCache.get(inst.getProcessDefId()); // 1. 记录本次审批动作,退回和同意都走这里 recordService.save(task, action, comment); // 2. 根据动作决定下一节点:同意走 nextNodeKey,退回走历史节点 String nextNodeKey = "REJECT".equals(action) ? historyService.findPrevNode(inst.getId(), task.getNodeKey()) : resolveNext(def, task.getNodeKey(), inst.getFormData()); // 3. 到达结束节点则关闭实例,否则解析审批人并建任务 if ("end".equals(nextNodeKey)) { instanceMapper.finish(inst.getId()); } else { ProcessNode node = def.getNode(nextNodeKey); List<Long> approvers = approverService.resolve(node.getApproverRule(), inst); taskService.createTasks(inst, node, approvers); instanceMapper.updateCurrentNode(inst.getId(), nextNodeKey); } taskMapper.finish(taskId); }

resolveNext处理条件节点时会把form_data平铺进上下文逐条求值,命中即返回;createTasks内部按审批人数量决定建一条还是多条,会签场景在下一节展开。这里要强调事务边界:记录、流转、建任务必须在同一个事务里,中途失败要整体回滚,否则会出现「有审批记录但任务没生成」的脏数据,这类问题在演示时特别致命。

4.3 前端可视化设计器:拖拽生成 JSON 并回显

设计器不追求功能大而全,把五类节点和连线做稳就够了。实现上左侧节点面板、中间画布、右侧属性面板三段式,画布用绝对定位 + SVG 画连线。拖入节点时生成nodeKey,拖拽结束时计算nextNodeKey,属性面板修改审批人规则时实时更新 JSON。

// 节点拖拽结束后,重建连线关系并同步到 store function rebuildEdges(nodes, edges) { const map = new Map(nodes.map(n => [n.id, n])); edges.forEach(e => { const from = map.get(e.source); const to = map.get(e.target); if (!from || !to) return; // 条件节点用 branches 表达多出边,普通节点直接接 nextNodeKey if (from.type === 'condition') { from.branches = from.branches || []; from.branches.push({ expression: e.expression || 'default', nextNodeKey: to.nodeKey }); } else { from.nextNodeKey = to.nodeKey; } }); return nodes; }

这段逻辑的关键是把画布上的「边」翻译成数据模型里的「下一节点」。条件节点有多条出边,用branches数组承载;普通节点只有一条出边,直接写nextNodeKey。回显时反向操作,遍历branches和nextNodeKey画出连线。设计器保存前调用后端的校验接口,把错误标红在节点上,避免用户点了发布才发现问题。

4.4 会签、或签、退回与加签的处理方式

这四种动作是 OA 的「深水区」,处理不好前面所有设计都会白费。

  • 会签:一个节点解析出多个审批人时,建多条oa_task,节点状态挂一个counter字段记录「已完成/总数」,全部通过才流转,任一驳回则整个节点驳回;
  • 或签:同样建多条任务,但任意一条完成即关闭同节点其余任务,用 Redis 分布式锁防止两人同时点通过;
  • 退回:不删记录,从oa_task_record里找到上一个审批节点,重新生成任务,实例的current_node回退,但保留历史记录;
  • 加签:在当前节点之前插入一个临时审批任务,用parent_task_id标记归属,完成后回到原节点继续。

或签的并发控制要单独说一句。两个人同时打开待办点通过,如果不加锁,会出现两条任务都完成、节点被流转两次的情况。做法是用instance_id + node_key作为 Redis 锁的 key,抢到锁的线程执行流转,没抢到的直接返回「该任务已被他人处理」。

5. 避坑与常见问题排查:五类最容易翻车的点

流程引擎这类系统,功能演示时往往很顺,一到并发或者版本切换就原形毕露。下面五条是实际项目里踩出来的,每条按「现象 → 原因 → 解决」写,对照排查能省不少时间。

5.1 待办列表显示旧节点名

现象:流程定义升级后,历史待办页显示的节点名称变成乱码或空白。

原因:oa_task只存了node_key,展示时去当前最新版流程定义里查节点名,而新版本可能已经删掉了该节点。

解决:任务创建时把node_name冗余进任务表,展示直接读冗余字段。流程定义只作为流转依据,不作为展示依据。同理,form_key也要在实例上冗余一份版本号。

5.2 条件表达式解析出空指针

现象:发起单据时报NullPointerException,日志指向表达式求值。

原因:表达式里的字段名和表单字段对不上,上下文取值为 null,直接参与比较就炸了。

解决:求值前先做字段存在性校验,缺失字段一律视为不满足分支,走 default。发布时校验表达式里的字段必须在绑定表单的 schema 中存在,把错误拦在发布阶段。

5.3 会签节点永远不结束

现象:会签节点所有人都审完了,流程还是停在那里。

原因:counter字段更新用了「先查后改」的非原子操作,并发下少加了一次;或者审批人列表里有重复用户 ID,导致任务数与预期不一致。

解决:counter用UPDATE ... SET done = done + 1 WHERE id = ?原子自增;创建任务前对审批人列表去重;结束判断用done >= total而不是done == total,留一点容错空间。

5.4 退回后重新提交进入死循环

现象:单据退回给发起人,改完重新提交,又回到了错误的节点。

原因:重新提交时复用了原实例的current_node,而这个值在退回时改成了发起节点,重新提交没有按流程定义重新计算。

解决:退回只改实例状态为「已退回」,重新提交时新建一次流转,从 start 节点重新走一遍,历史记录通过parent_instance_id关联。虽然多一条实例记录,但状态清晰,不会绕晕。

5.5 停用流程导致在途单据报错

现象:管理员停用了旧版本流程,在途单据点审批时提示「流程定义不存在」。

原因:流转时按process_key取最新启用版本,而不是按实例绑定的process_def_id取。

解决:实例启动时锁定process_def_id,后续所有流转都用这个 ID 查定义,与流程是否停用无关。停用只影响新发起,不影响在途。这条是版本管理里最容易写错的地方,代码 review 时要专门看一眼。

6. 验收与答辩:怎么证明「流程可自定义」真的成立

验收这套系统,别只演示一次「发起→审批→结束」,那证明不了可自定义。真正有说服力的验收是现场改一条流程,不改代码、不重启服务,让流程立刻生效。下面这张清单可以照着走一遍:

验收项操作通过标准
新增审批节点在设计器里加一个「总监审批」并发布新发起的单据多出该节点,旧单据不受影响
修改条件阈值把金额阈值从 5000 改成 3000 并发布3000 以上的单据走财务分支
切换审批人规则把部门经理改为按角色匹配审批人变化,且历史记录仍显示原审批人
退回重提发起后被退回,修改后重新提交从发起节点重新走,历史记录完整保留
停用旧版本停用 v1 后审批在途单据在途单据正常流转,新单据走 v2

答辩时最容易被追问的三个问题,提前准备好答案:一是「流程定义改了,在途单据怎么办」,答版本锁定;二是「两个人同时审批同一个节点会怎样」,答分布式锁 + 原子计数;三是「条件表达式会不会被执行恶意代码」,答只支持字段比较、不引入表达式引擎。这三个问题答得出来,基本就说明设计想清楚了。

有个习惯我后来一直保留:每次演示前,先用一套造好的测试数据把上面五条验收项跑一遍,尤其是退回重提和版本停用这两条,最容易在演示现场翻车。有一次就是没跑退回,现场点下去流程卡住,只能尴尬地说「这是网络问题」。从那以后我每次交付前都强制走一遍验收清单,不再靠临场发挥。希望这套方案和清单能帮到你,把这套 OA 系统稳稳地交出去。

本文还有配套的精品资源,点击获取

返回列表