☰
企业审批流程引擎设计:数据模型、状态机与并发控制的落地实践
2026/10/18 19:52:02 网站建设 项目流程

简介:这是一款面向企业、机关单位的多场景办公审批微信小程序,覆盖请假、报销、外出、合同、采购、入职及其他审批流程,从申请提交到审批完结全程线上化。系统内设管理员、员工、审批人员三种角色,权限分明,审批结果可通过微信消息实时提醒申请者,适合需要规范审批流程的团队或开发者参考。压缩包共489个文件,含js、wxss、wxml、json等微信小程序核心文件,另有png/jpg图标与docx安装使用手册,整体仅2.12MB,目录层次清晰,便于快速部署与二次开发。内含数据操作、页面辅助、二维码生成等工具模块,以及图表展示组件,说明资源不仅包含完整业务逻辑,还具备通用扩展能力。目前已有47人浏览学习,适合中小企业管理者、行政人员及微信小程序开发者直接借鉴或扩展使用。

1. 企业办公审批小程序方案:先想清楚流程定义,再写业务代码

审批流程看起来不难,真正落地时最容易卡在流程定义这一层。一张请假单要跑三四级,一张采购单要跑五级,合同单还牵扯会签、金额校验和附件留痕,如果每种单据都单独写一套 if else,流程一调整就得改代码。这套企业办公审批小程序方案的核心,是把请假、报销、外出、合同、采购这些常见审批场景收敛成一套可配置的流程引擎:表单字段允许不同,流转骨架保持一致,适合中小团队、企业内网或机关单位直接搭办公审批后台,也适合计算机方向的后端项目作为完整 Demo 来复现。全文我会拆数据模型、流程引擎、前端权限和上线避坑,照着跑起来就能得到一条可用的审批链。

2. 数据模型先行:一张 bill 表如何同时装下六类审批单

2.1 表结构:把“单据本身”与“流程实例”揉在一起,还是拆开

我第一次做这种系统时,老实按业务类型各建了一张表:请假表、报销表、合同表、采购表,结果每加一种审批类型就要复制一遍增删改查。后来改成单表挂流程的做法,一张bill表承担单据属性,一张bill_audit装审批轨迹,一张flow_def存流程步骤模板,三种类型共用一套引擎。

-- 核心单据表:一张表承接所有审批类型 CREATE TABLE bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_no VARCHAR(32) NOT NULL COMMENT '单据编号,例如 LEAVE-202503-001', bill_type TINYINT NOT NULL COMMENT '1请假 2报销 3外出 4合同 5采购 6用印', title VARCHAR(128) NOT NULL COMMENT '标题:请休假天数/报销事由/采购物品名称', amount DECIMAL(12,2) DEFAULT 0 COMMENT '涉及金额,统一DECIMAL,报表直接SUM', applicant_id BIGINT NOT NULL COMMENT '发起人ID', dept_id BIGINT NOT NULL COMMENT '发起人部门ID,用于推导审批人', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1审批中 2已通过 3已驳回 4已撤回 5退回修改', current_step_no INT NOT NULL DEFAULT 0 COMMENT '当前第几步,从1开始', current_approver_id BIGINT DEFAULT NULL COMMENT '当前待办人ID,多审批人时存首个待办人', form_data JSON COMMENT '各类型专属字段,如请假起止日期、合同金额、供应商', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_approver (status, current_approver_id) );

这段 DDL 的关键点是current_step_no和current_approver_id两个字段,它们是把“流程状态”直接冗余到单据表上的核心。form_data用 JSON 保存差异字段,让“请假要填起止日期、报销要填发票号、合同要填乙方主体”这些差异不扩散成表结构。amount统一用DECIMAL(12,2),避免后续做金额汇总时踩字符串转数字的坑。

还有一张审批记录表,每次操作都落一条记录,方便追溯和统计:

CREATE TABLE bill_audit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_id BIGINT NOT NULL, action TINYINT NOT NULL COMMENT '1提交 2通过 3驳回 4退回 5撤回 6转办', from_step INT COMMENT '从第几步来', to_step INT COMMENT '走向第几步,驳回则回退', operator_id BIGINT NOT NULL COMMENT '操作人ID', comment VARCHAR(512) COMMENT '审批意见', created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

这张表不是日志表,它是流程状态的“黑匣子”。会签统计、超时提醒、驳回重审后的历史意见折叠,全都依赖它。我的习惯是from_step和to_step一定要填,否则后面写“退回修改后重新提交”时,根本不知道单据之前卡在哪一步。

2.2 状态位与步骤模板:草稿、审批中、通过、驳回之外,还要“退回修改”

很多初版设计只设“待审批、通过、驳回”三个状态,跑两个月就发现不够:员工填单填到一半要暂存,需要草稿态;审批人口误点错了,需要撤回重提;驳回后不是终止而是补充材料再提交,需要退回修改态。最终我保留五个状态:

状态值状态名触发动作
0草稿创建单据不提交
1审批中提交成功或驳回后重提
2已通过最后一步审批通过
3已驳回终审驳回,流程终止
4已撤回审批中发起人主动撤回
5退回修改非终局驳回,允许修改后重提

状态转移必须由后端校验,不能只靠前端按钮控制。比如草稿态才能提交、审批中才能审批、驳回态不能直接改成通过,这些如果散落在业务代码里,每加一个接口就多一个漏洞出口。我会在服务层做一个统一的状态机校验方法,所有接口进入前先过一遍。

步骤模板用flow_def表存储,结构里直接放steps_json:

CREATE TABLE flow_def ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bill_type TINYINT NOT NULL UNIQUE, version INT NOT NULL DEFAULT 1, steps_json JSON NOT NULL -- 示例: -- [{"stepNo":1,"rule":"dept_manager","mode":"ANY"}, -- {"stepNo":2,"rule":"hr_owner","mode":"ANY"}] );

rule字段表示审批人解析规则,mode表示当前步骤的审批模式:ANY是或签,JOIN是会签。JSON 存储的好处是流程定义可以整体替换、版本化,坏处是无法用 SQL 直接查询具体步骤,但流程定义变化频率很低,这种取舍在中小系统里完全够用。

2.3 审批人推导规则:不要把审批人写死在流程里

流程模板里不存具体人,只存“规则”。比如请假审批的第二步应该找部门经理,但部门经理可能是张三、李四,需要根据发起人动态查出来。我常用的规则有:

  • dept_manager:查user表,满足dept_id = bill.dept_id且role = 'manager'
  • project_owner:查project_member表,拿到项目负责人
  • hr_owner:按发起人所在组织架构找对应 HR
  • leader:查user.leader_id,取发起人的直属上级
  • finance_review:固定角色role = 'finance'

规则解析出一个或若干候选审批人,再把候选人塞进当前待办人字段。如果同一级有多个审批人且是“或签”,任选一人即可;如果是“会签”,需要额外建一张待办明细表,我在第 3 章展开。

3. 后端流程引擎:提交、通过、驳回、退回的推进顺序与并发边界

3.1 提交与推进:submit / approve / reject 的职责边界

流程引擎的核心不在于写多少代码,而在于每个方法只能做“一件事”。我一般把操作拆成四个接口:submit、approve、reject、return,其中return对应“退回修改”,reject对应“终局驳回”。先看提交逻辑:

// 触发条件:草稿、已驳回、退回修改 三种状态允许提交 public Long submit(Long billId, Long operatorId) { Bill bill = billDao.get(billId); if (!operatorId.equals(bill.getApplicantId())) { throw new BizException("只有发起人可以提交"); } if (!Set.of(BillStatus.DRAFT, BillStatus.REJECTED, BillStatus.RETURNED) .contains(bill.getStatus())) { throw new BizException("当前状态不允许提交"); } FlowDef flow = flowDefDao.latest(bill.getBillType()); List<FlowStep> steps = flow.steps(); bill.setCurrentStepNo(1); bill.setCurrentApproverId(resolveApprover(steps.get(0), bill)); bill.setStatus(BillStatus.PENDING); billDao.updateWithVersion(bill); auditDao.insert(new Audit(bill, Action.SUBMIT, 0, 1, operatorId, "提交审批")); return billId; }

这里两个细节要注意。第一,状态为REJECTED时也能再次提交,意味着终局驳回后系统允许“换个理由重走流程”,这在办公场景里很常见;第二,无论从哪个状态进入提交,都强制把current_step_no重置为 1,避免驳回重提后从上一次卡住的第二步继续跑。resolveApprover是推导规则的方法,参数是当前步骤和单据,返回一个审批人 ID。

3.2 会签、或签与条件分支:把“多人审批”塞进同一套推进逻辑

审批推进最难写的不是线性链路,而是会签。合同审批经常“法务、财务、总经理三人会签”,每人意见都要记录,且三人全部同意才能进入下一步。推进方法:

public void approve(Long billId, Long operatorId, String comment) { Bill bill = billDao.getForUpdate(billId); if (!BillStatus.PENDING.equals(bill.getStatus())) { throw new BizException("单据不在审批中"); } if (!operatorId.equals(bill.getCurrentApproverId())) { throw new BizException("你不是当前待办人"); } FlowStep step = flowDefDao.latest(bill.getBillType()).stepOf(bill.getCurrentStepNo()); auditDao.insert(new Audit(bill, Action.AGREE, bill.getCurrentStepNo(), bill.getCurrentStepNo(), operatorId, comment)); if (step.isJoinMode()) { int agreed = auditDao.countByBillAndStepAndAction(billId, bill.getCurrentStepNo(), Action.AGREE); if (agreed < step.approvers().size()) { return; // 会签未齐,不推进流程 } } moveToNextStep(bill); billDao.updateWithVersion(bill); }

会签的关键在于“中间态”。普通审批按下通过后,单据立即推给下一步;会签按下同意后,只是当前投票数加一,当累计同意数达到该步骤审批人总数时,才真正推进。所以前端在“会签进行中”要能显示“已同意 2/3”,这个数据可以直接从bill_audit里按步骤统计。getForUpdate是行锁,防止两个审批人同时点击通过时产生并发冲突。

3.3 并发控制与幂等:为什么必须用乐观锁,而不是先查再改

审批系统最容易出现的线上事故,是同一张单据被两个审批人同时操作,或同一个按钮被前端连点两次。第一步先查状态、第二步再更新的做法,中间隔着一次网络交互,并发时两条请求都读到“待审批”,最后直接覆盖更新。解决方式有两种,我倾向乐观锁:

UPDATE bill SET status = #{newStatus}, current_step_no = #{newStepNo}, current_approver_id = #{newApproverId} WHERE id = #{billId} AND status = 1 AND current_approver_id = #{operatorId}

这条 SQL 的更新行数如果为 0,说明单据状态已经被别人动过,或者操作人已经不是当前待办人。后端拿到 0 直接抛“单据状态已变化,请刷新查看”,而不是继续执行后续逻辑。配合@Transactional,可以保证审计记录和单据更新要么同时成功,要么同时回滚。

还有个细节是幂等。前端提交时生成一个request_id,后端在bill_audit上做唯一索引,如果同一人同一单据的同一动作重复到达,直接丢弃第二次请求。这个字段在接口层透传即可,成本很低,但能避免很尴尬的“双通过”问题。

4. 前端与权限:把“谁能审批”交给后端,把“可点按钮”渲染出来

4.1 待办列表的口径:只看当前步骤的待办人字段

待办列表的查询口径很直白:status = PENDING且current_approver_id = 当前用户ID。不需要额外建任务表:

SELECT id, bill_no, title, bill_type, created_at FROM bill WHERE status = 1 AND current_approver_id = #{userId} ORDER BY created_at DESC LIMIT 20

如果是会签节点,一张单子同时有多个待办人,单一current_approver_id存不下。常见做法是拆一张bill_todo_user表,或者用current_approver_ids字段存逗号分隔,查询用FIND_IN_SET(userId, current_approver_ids)。数据量在十万以内时FIND_IN_SET完全够用,不必为了这个单独建表。

小程序端拉取待办列表时,我一般把“可做操作”一并返回,避免前端再调一次权限接口:

public class TodoVO { private Long billId; private String title; private String billNo; private String applicantName; private List<String> permissions; // 例如 ["approve","reject","return"] }

后端组装permissions的逻辑是:先查该用户在bill_audit中有没有历史操作记录,再结合当前步骤模式和用户是否为待办人,最后返回他能执行的按钮集合。这样做,前端拿到什么就渲染什么,不用再写“if 角色 == 经理”。

4.2 按钮渲染:后端返回“可操作位”,前端不做角色判断

小程序页面上的“通过”“驳回”按钮,不应由前端判断“我是谁”来控制显隐,而应该由后端给出当前用户的权限位。前端渲染起来非常机械:

<view class="todo-card" wx:for="{{todos}}" wx:key="billId"> <view class="todo-title">{{item.title}}</view> <view class="todo-meta">{{item.billNo}} · {{item.applicantName}}</view> <view class="todo-actions"> <button wx:if="{{item.permissions.indexOf('approve') >= 0}}" bindtap="approve">Page({ data: { todos: [] }, onShow() { this.loadTodos(); }, loadTodos() { wx.request({ url: '/api/todo/list', data: { page: 1, pageSize: 20 }, success: (res) => { this.setData({ todos: res.data.list }); } }); }, approve(e) { const billId = e.currentTarget.dataset.id; wx.request({ url: `/api/bill/${billId}/approve`, method: 'POST', success: () => { this.loadTodos(); } }); } });

这里有个细节:审批人点击“通过”后,列表要立即刷新,但商品卡片的permissions是后端快照,刷新后自然移除该条单据。如果后端在approve接口中因为乐观锁返回“状态已变化”,前端不能吞掉错误,要弹出提示并重新loadTodos()。我在按钮回调里统一加一个fail分支刷新列表,避免用户看到旧数据。

4.3 详情页:审批意见的时间线要区分“历史意见”和“当前步骤意见”

单据详情页最容易让用户困惑的不是布局,而是审批意见的排序。请假单被驳回后修改重提,之前的“驳回”意见如果还是按最新时间置顶,第二次审批人一进来就看到一条“不同意”,很容易错判。我的做法是把bill_audit记录分成两段展示:第一条到当前步骤之间的意见按时间正序排,当前步骤之后的意见折叠到“历史意见”分组里。后端返回详情时直接给出两个数组:

{ "bill": { "id": 1001, "status": "PENDING" }, "currentOpinions": [ { "operator": "张三", "action": "通过", "comment": "同意,注意日期", "time": "2025-03-10 10:00" } ], "historyOpinions": [ { "operator": "李四", "action": "退回修改", "comment": "发票信息不完整", "time": "2025-03-08 17:00" } ] }

前端只需渲染两个分组,不需要自己算“哪条是历史”。分组逻辑放在后端查询时判断created_at是否早于该单据最近一次SUBMIT时间。这个设计我调整过两版,第一版让前端判断时差点把“当前退回意见”归类到历史,后来统一改成后端分组,前端只做展示。

5. 避坑与常见问题:审批流上线后最容易翻车的五个点

5.1 同一张单子被两个人同时“通过”,单据直接变成已通过

现象:合同审批单在会签节点,法务和财务同时点击“通过”,两个请求都成功,bill_audit里出现两条同意记录,但单据跳过了下一步,直接变成“已通过”。

原因:后端是先查状态再更新,两个请求都查到status = PENDING,都通过了校验,导致状态被覆盖推进。会签节点的同意逻辑里,没有把“统计前投票数”和“推进状态”放进事务。

解决:推进逻辑加行锁,approve方法第一步就SELECT ... FOR UPDATE锁住bill行;同时对bill_audit增加bill_id + action + operator_id + step_no联合唯一索引,重复操作直接抛异常回滚。从那以后,我把所有审批动作都套上了事务模板,再没出过双通过事故。

5.2 驳回修改后重提,流程没有回到第一步

现象:员工请假被行政驳回后修改了日期重新提交,结果审批人直接变成了“副总经理”,跳过了部门经理。

原因:重提接口只更新了状态和发起时间,没有重置current_step_no。服务端在旧的步骤上继续推进,从第二步开始。

解决:submit方法里,无论单据之前处于什么状态,统一执行bill.setCurrentStepNo(1)并从流程定义第一步开始解析审批人。同时,驳回重提时要把之前的审批轨迹保留,不清空bill_audit。这个规则我在代码注释里标了“强制回第一步”,避免后续维护时被误优化掉。

5.3 审批人离职或调岗,流程直接卡死在“审批中”

现象:某采购单的审批人调岗后,流程一直停在“待审批”,新领导看不到,员工也不敢撤回。

原因:current_approver_id只存了一个人,人员变动后没有自动重新推导审批人,也没有定时任务去扫描长尾单据。

解决:加一个每 15 分钟跑一次的重分配任务,扫描status = PENDING且updated_at超过 2 小时的单据,重新执行resolveApprover更新current_approver_id。同时给发起人加“撤回”按钮,让员工在审批人失联时能主动终止单据。这个兜底逻辑不复杂,但能救回不少用户信任。

5.4 金额字段用字符串存储,报表统计乱套

现象:合同审批的金额字段在form_data里存成了"12,000.00",带逗号带单位,报表 SUM 结果错得离谱。

原因:JSON 字段里塞了人工录入的原始字符串,后端的DECIMAL转换没做清洗。不同单据类型的金额格式不统一。

解决:所有涉及金额的单据,在提交时把最终金额冗余写入bill.amount字段,统一DECIMAL(12,2)。合同审批里如果包含税额、运费,则在form_data里存明细,bill.amount只存汇总值。报表全部从bill.amount取数,不再解析 JSON。这个冗余设计解决了 80% 的统计问题。

5.5 会签节点“同意一半”时误操作导致推进

现象:合同单会签要求三个审批人全部同意,其中一人点了“通过”,结果单据直接跳到了下一级,另外两个人还没看。

原因:会签的mode判断写成了“当前审批人存在即通过”,没有统计该步骤累计同意数。审批人数量与已同意数量不匹配时,应该停留在当前步骤而不是推进。

解决:统计bill_audit中当前步骤的AGREE记录数,和step.approvers().size()比较,不齐就只记AGREE不推进。前端列表里同步显示“已同意 1/3”,让后续审批人知道还有多少人待批。会签逻辑我每次上线前都会手测一遍“两人同意第三人不通过”的场景,确保能停在原地。

6. 验收与进阶:流程自检脚本和新旧版本共存技巧

6.1 一条命令跑完整链路冒烟

审批系统上线前,我会写一个 shell 自检脚本,覆盖“创建单据、提交、第一步审批、会签统计、最后通过”全链路。每次后端改动后直接跑一遍,五分钟内确认核心流程没被改坏:

#!/bin/bash set -e BASE=http://127.0.0.1:8080 MGR_TOKEN=$(curl -s -X POST $BASE/api/login -H "Content-Type: application/json" \ -d '{"account":"manager01"}' | jq -r '.token') EMP_TOKEN=$(curl -s -X POST $BASE/api/login -H "Content-Type: application/json" \ -d '{"account":"emp01"}' | jq -r '.token') BILL_ID=$(curl -s -X POST $BASE/api/bill -H "Authorization: Bearer $EMP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"billType":"leave","title":"年假3天","form":{"start":"2025-04-01","end":"2025-04-03"}}' \ | jq -r '.id') curl -s -X POST $BASE/api/bill/$BILL_ID/submit -H "Authorization: Bearer $EMP_TOKEN" curl -s -X POST $BASE/api/bill/$BILL_ID/approve -H "Authorization: Bearer $MGR_TOKEN" \ -d '{"comment":"同意"}' STATUS=$(curl -s $BASE/api/bill/$BILL_ID -H "Authorization: Bearer $EMP_TOKEN" | jq -r '.status') test "$STATUS" = "PASSED" && echo "请假审批全链路通过" || { echo "流程未通过"; exit 1; }

脚本里的jq是必备工具,如果服务器没装就用python3 -c "import sys,json; print(json.load(sys.stdin)['token'])"替代。跑通后再查一遍bill_audit的记录序列,确认是“提交、通过”,没有多一条重复记录。

6.2 进阶:流程定义版本化,让存量单据继续走旧流程

另一个值得做的进阶是把flow_def做成版本化。流程调整不能一刀切,比如采购审批从两步改成三步,在途旧单子应该继续按旧规则走。我的做法是bill表增加flow_version字段,submit时记录当前流程定义版本,后续approve时读取该版本而不是最新版本:

{ "steps": [ { "stepNo": 1, "rule": "dept_manager", "mode": "ANY" }, { "stepNo": 2, "rule": "finance_review", "mode": "ANY" } ], "version": 3 }

新版本上线后只影响新提交的单据,存量单据在flow_def里找到对应版本继续推进。这个设计让“改流程”从高风险操作降级成普通配置操作。我曾经把一个合同流程从三步改成五步,没加版本控制导致一堆在途单据全部卡死。从那以后,我每次上线流程调整都强制走一遍自检脚本,然后手动跑两单新旧流程的单据,确认它们都能正常到点。这套流程定义加版本协同的思路,才是审批系统后期维护里最值钱的部分,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询