since 用户输入的项目正文和关键词、摘要描述都为空,我需要完全基于标题来发挥,把我自己代入到“刚做完一套工作流系统的开发者”角度,写一篇完整、有干货、有实际经验的博文。下面是正文。
这个项目不是团队拍脑袋决定的。当时手头接到一个内部系统的改造需求:单位里请假、报销、用印申请还在走纸质流程,审批单贴着标签在各部门之间传递,一到月底财务对账就要翻半天的纸质单子。目标很明确——用一套基于SpringBoot和Vue的工作流程管理系统把这些日常审批搬到线上,后端用Java + MySQL + MyBatis,前端用Vue,最终交付一份可以直接编译运行的完整源码。
这套系统做完之后,我把它定位成“轻量级审批中台”,没有去套商业产品的复杂概念。它管的事儿很聚焦:流程发起、逐级审批、驳回重提、撤回、待办已办、审批历史追踪。技术上没有用现成的BPM引擎,而是基于MyBatis操作MySQL数据表,配合SpringBoot的事务控制,自己实现了一个状态机式的流程引擎。这篇文章就按我实际开发顺序,把整个项目的需求拆解、表结构设计、核心流转逻辑、前端交互、踩坑记录和部署步骤完整捋一遍,给正在做类似毕设或者想给团队做内部工具的开发者一个可以直接参照的样板。
1. 为什么没直接套现成流程框架,而是手写这套工作流
1.1 系统要解决的三个真实场景
先说我接手时面对的业务模型。最典型的就是三类流程:
- 请假审批:员工发起 → 部门主管审批 → 人事备案 → 结束。
- 报销审批:员工发起 → 部门主管审批 → 财务审核 → 总经理审批 → 结束。
- 用印申请:员工发起 → 部门主管审批 → 行政复核 → 结束。
这三类流程看起来简单,但业务上有一个共同点:每个节点只有一个审批人,审批顺序是固定的,没有会签、没有条件分支、没有复杂的并行网关。也就是说,本质上就是一条单向链表,每个节点持有下一个节点的引用,走完最后一个节点流程就结束。
我当初也认真考虑过直接引入现成的开源BPM引擎,毕竟“工作流程管理系统”这个词很容易让人想到那套BPMN2.0规范。但看完文档之后我放弃了,原因很现实:内部系统总共就三五个流程模板,每个模板最多四五个审批节点,引入一套完整流程引擎的代价是学习BPMN模型、部署流程定义文件、维护引擎自带的那几十张扩展表。杀鸡用牛刀不说,一旦流程规则要微调,还得去翻官方文档。
1.2 自研轻量引擎与现成BPM框架的对比
我把两种方案的取舍做了个表,供大家选型时参考:
| 对比维度 | 自研轻量引擎(本项目的做法) | 现成开源BPM引擎 |
|---|---|---|
| 学习成本 | 低,核心逻辑约200行代码 | 高,需要理解一套标准规范 |
| 流程灵活性 | 固定节点链,适合内部审批 | 高,支持复杂网关、会签、子流程 |
| 运行资源 | 无额外组件,纯业务数据表 | 依赖额外服务或大量数据表 |
| 二次开发效率 | 加流程只需加两条数据库记录 | 改流程要部署模型文件 |
| 适合场景 | 中小团队内部工具、毕业设计 | 大型复杂流程平台 |
最终选择自研还有一个很大的好处:流程状态完全在自己手里。业务里遇到“驳回后再修改重提”“发起人撤回”“审批到一半人员离职换人”这类刁钻场景,我可以直接改数据库状态、直接改SQL,不用去猜引擎内部机制。对于一个管理者只有几十个人的单位,这套方案绝对够用了。
2. 八张表搭起流程骨架:核心表结构与设计思路
这个项目的数据模型是整篇的精华之一。我的核心设计思路是:把“业务表单”和“审批流状态”彻底分离。业务表单长什么样,由前端配置的JSON决定;审批流走到哪了、谁该审批、历史上审过几次,由独立的流程状态表记录。这样任何一类的流程都不需要单独建业务表。
2.1 组织与用户:审批人是谁、怎么定位
审批流首先要解决“人”的问题。我们系统里用到的组织级基础表有四张:
sys_user:用户表。关键字段是id、username、password、real_name、department_id。sys_department:部门表。关键字段是id、dept_name、leader_user_id,这个leader_user_id非常关键,它直接指向部门负责人,是“主管审批”这个节点的找人依据。sys_role:角色表。为了不把权限系统做太复杂,我直接用role_key区分角色枚举,比如dept_leader(部门主管)、finance(财务)、admin(行政)、hr(人事)。sys_user_role:用户-角色关联表。一个用户可以有多个角色。
这里我踩过一个设计上的纠结:审批节点上到底存“人”还是存“角色”?最后定的结论是节点上配置角色,运行时解析具体用户。原因特别简单:主管是有任期的,财务人员会换岗,如果我节点上写死用户ID,换人之后所有历史流程模板都要改;写死角色,换人之后只需要调整用户授权,流程模板纹丝不动。
2.2 流程定义与节点:把一条审批链拆成半张表
流程模板用两张表描述:
flow_definition:流程定义表,记录流程的基本信息。字段:id、def_key(唯一业务标识,如leave)、def_name(中文名称,如请假审批)、form_schema(前端表单配置JSON)、status(启用/停用)。flow_node:流程节点表,一个流程有多个节点,节点之间靠sort字段排序。字段:id、def_id、node_key、node_name、node_type(START起始节点、APPROVE审批节点、END结束节点)、role_key(当前节点允许审批的角色)、sort、next_sort(指向下一节点的排序值)。
每个节点就是一个链表元素,找下一个节点就是查同一条def_id下sort等于当前节点next_sort的那一行。请假流程在数据库里就是三行:起始节点(发起人)、审批节点(部门主管角色)、结束节点(人事备案角色)。虽然我叫它“节点表”,实际它在结构和逻辑上就是一个审批链。
2.3 实例、任务、历史:审批过程的三层数据
审批过程中的动态数据,我拆成了三张核心表:
flow_instance:流程实例表。每一次“张三提交了一个请假申请”就是一条实例。关键字段:id、def_id、title(申请标题)、applicant_id(发起人)、current_node_id(当前所在节点)、status(状态枚举)、form_data(表单数据JSON)、version(重提版本号)。flow_task:流程任务表。严格说它应该叫“待办任务表”,因为当前只有一个待办任务在流转。字段:id、instance_id、node_id、assignee_id(指定审批人)、status(PENDING待处理/DONE已处理)、handle_time、comment。flow_history:审批历史表。每次有人点击“通过”或“驳回”,就追加一条历史。字段:id、instance_id、node_id、operator_id、action(PASS通过/REJECT驳回/RETRACT撤回)、comment、operate_time。
流程实例表记录的是“这件事的全局进度”,任务表记录的是“当前这张待办”,历史表记录的是“每一步脚印”。查询“流程走到哪了”看实例表,查询“我有哪些待办”看任务表,查询“审批轨迹”看历史表,各司其职。
这里给出flow_instance和flow_task的简化建表SQL,完整的DDL脚本在源码里:
CREATE TABLE `flow_instance` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `def_id` bigint(20) NOT NULL COMMENT '流程定义ID', `title` varchar(200) NOT NULL COMMENT '申请标题', `applicant_id` bigint(20) NOT NULL COMMENT '发起人ID', `current_node_id` bigint(20) DEFAULT NULL COMMENT '当前节点ID', `status` varchar(20) NOT NULL DEFAULT 'RUNNING' COMMENT '状态', `form_data` text COMMENT '业务表单JSON', `version` int(11) NOT NULL DEFAULT '1' COMMENT '重提版本', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `flow_task` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `instance_id` bigint(20) NOT NULL COMMENT '流程实例ID', `node_id` bigint(20) NOT NULL COMMENT '流程节点ID', `assignee_id` bigint(20) NOT NULL COMMENT '审批人ID', `status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', `comment` varchar(500) DEFAULT NULL COMMENT '审批意见', `handle_time` datetime DEFAULT NULL COMMENT '处理时间', PRIMARY KEY (`id`), KEY `idx_instance` (`instance_id`), KEY `idx_assignee_status` (`assignee_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;索引设计上有两个值得注意的地方:业务表主键一律用自增bigint,省心;flow_task要建(assignee_id, status)联合索引,因为这个系统日常访问频率最高的查询就是“某人的待办列表”。
2.4 为什么业务表单要用JSON存,而不是每类流程建一张表
这是设计讨论时长最长的部分。按常规想法,请假单应该有一张leave_form表,报销单应该有一张reimburse_form表,字段各自定义。这个方案优点是字段强类型、可以针对某类流程做统计报表;缺点是每增加一个新流程,就要新加一张表、新增一套增删改查的接口,工作量爆炸。
我的选择是:在flow_definition表里存一个form_schemaJSON,描述这个流程的表单长什么样;在flow_instance表里存一个form_dataJSON,存发起人提交的实际数据。
{ "fields": [ { "label": "请假天数", "prop": "days", "type": "number", "required": true }, { "label": "开始日期", "prop": "startDate", "type": "date", "required": true }, { "label": "请假原因", "prop": "reason", "type": "textarea", "required": true } ] }提交下来的数据是:
{ "days": 3, "startDate": "2025-05-12", "reason": "回老家处理事情" }好处很明显:以后要加一个“采购申请”,不需要写任何后端代码,在流程配置页面插入一条流程定义、插两条节点记录,就能跑通。JSON的本质让系统获得了“后来者免开发”的扩展性。当然这个方案也有代价,比如没法直接用SQL去统计“所有人这个月的请假天数之和”,遇到这种需求只能遍历解析JSON或者在应用层做聚合。对这个规模的管理系统来说,这种取舍是划算的。
3. 一次完整审批的后端流转:从发起、审批到归档
3.1 发起审批:创建实例和第一个待办任务
后端的流程引擎核心类叫ProcessEngineService,所有流转逻辑都收敛在这个服务里,外面Controller层只负责参数接收和权限判断。
发起流程时,前端提交的数据是:流程定义ID、申请标题、表单数据JSON。后端走的是一个带@Transactional注解的事务方法:
@Transactional(rollbackFor = Exception.class) public Long startProcess(Long defId, Long applicantId, String title, String formData) { ProcessDefinition def = definitionMapper.selectById(defId); if (def == null || def.getStatus() != 1) { throw new BizException("流程不存在或已停用"); } FlowNode firstNode = nodeMapper.selectFirstNode(defId); FlowInstance instance = new FlowInstance(); instance.setDefId(defId); instance.setTitle(title); instance.setApplicantId(applicantId); instance.setFormData(formData); instance.setStatus("RUNNING"); instance.setCurrentNodeId(firstNode.getId()); instanceMapper.insert(instance); Long approverId = resolveApprover(firstNode, applicantId); flowTaskMapper.insert(FlowTask.builder() .instanceId(instance.getId()) .nodeId(firstNode.getId()) .assigneeId(approverId) .status("PENDING") .build()); return instance.getId(); }这个方法里最关键的是resolveApprover——解析当前节点的审批人。解析规则按照节点配置的role_key分两种场景:
- 如果
role_key是部门主管,那就从sys_department表根据发起人department_id查出leader_user_id,得到唯一的审批人。 - 如果
role_key是财务、行政、人事这类职能角色,就查该角色下某个在职用户作为当前审批人。
这个设计把“审批人随着组织结构变化而变化”的问题从根上解决了。某个部门换主管,只需改sys_department.leader_user_id;财务换人,只需调整sys_user_role关联,流程模板一行都不用动。
3.2 通过、驳回、撤回:三组动作的状态推进
审批动作的入口是一个handleTask方法,区别只在传入的action参数。经过实际编码后我强烈推荐把“通过”“驳回”“撤回”设计成同一个入口的三种动作,而不是三个独立接口,因为它们的共性太强了——都要更新任务状态、都要写历史、都要动实例节点。
@Transactional(rollbackFor = Exception.class) public void handleTask(Long taskId, Long operatorId, String action, String comment) { // 1. 顺着任务查出自己的业务数据 FlowTask task = flowTaskMapper.selectById(taskId); FlowInstance instance = instanceMapper.selectById(task.getInstanceId()); // 2. 校验任务归属 if (!task.getAssigneeId().equals(operatorId)) { throw new BizException("无权处理此任务"); } // 3. 乐观锁更新任务,防止并发重复审批 int rows = flowTaskMapper.updateStatusById(taskId, "DONE", operatorId, comment, "PENDING"); if (rows == 0) { throw new BizException("任务已被处理,请刷新后查看"); } // 4. 记录审批历史 historyMapper.insert(FlowHistory.builder() .instanceId(instance.getId()) .nodeId(task.getNodeId()) .operatorId(operatorId) .action(action) .comment(comment) .build()); // 5. 根据动作推进流程 if ("PASS".equals(action)) { advanceNext(task, instance); } else if ("REJECT".equals(action)) { instance.setStatus("REJECTED"); instanceMapper.updateById(instance); } else if ("RETRACT".equals(action)) { instance.setStatus("CANCELED"); instanceMapper.updateById(instance); } }advanceNext的逻辑就是链表移动:
private void advanceNext(FlowTask task, FlowInstance instance) { FlowNode currentNode = nodeMapper.selectById(task.getNodeId()); FlowNode nextNode = nodeMapper.selectNextNode(instance.getDefId(), currentNode.getNextSort()); if (nextNode == null || NodeType.END.equals(nextNode.getNodeType())) { instance.setStatus("FINISHED"); instance.setCurrentNodeId(null); instanceMapper.updateById(instance); return; } instance.setCurrentNodeId(nextNode.getId()); instanceMapper.updateById(instance); Long approverId = resolveApprover(nextNode, instance.getApplicantId()); flowTaskMapper.insert(FlowTask.builder() .instanceId(instance.getId()) .nodeId(nextNode.getId()) .assigneeId(approverId) .status("PENDING") .build()); }这里尤其需要注意:通过动作和驳回动作对实例状态的处理完全不同。通过后实例要么跳到下一节点,要么直接办结;驳回后实例状态直接变成REJECTED,流程冻结。发起人在“我的申请”里看到被驳回的实例后,可以点击“修改重提”,那时代码会把实例状态改回RUNNING、版本号加1、创建回第一个审批节点的待办,这个操作其实就是一次“重新启动当前实例”。
3.3 待办与已办列表:怎么用一条SQL带出关联信息
待办已办是前端使用频率最高的接口,SQL设计直接决定体验。如果图省事,先查任务表循环再查实例表,那列表永远慢得让人想骂人。我最终的待办查询是一条覆盖索引的JOIN:
SELECT t.id AS task_id, i.title, i.form_data, d.def_name AS process_name, u.real_name AS applicant_name, t.create_time FROM flow_task t INNER JOIN flow_instance i ON t.instance_id = i.id INNER JOIN flow_definition d ON i.def_id = d.id INNER JOIN sys_user u ON i.applicant_id = u.id WHERE t.assignee_id = #{userId} AND t.status = 'PENDING' ORDER BY t.create_time DESC这条SQL把流程名称、发起人姓名、表单数据一次性全部带出来,避免了循环查库。已办列表基本一样,只是把t.status = 'PENDING'改成'DONE',再加t.handle_time降序。
这里有个前端联调时的小约定:form_data是字符串类型的JSON,我在后端返回前不做解析,直接把字符串丢给前端,让前端在渲染详情时自己JSON.parse。这样做的理由很简单——详情页的渲染本来就是前端职责,后端不需要关心表单里有哪几个字段,否则后端的Model层还要写各种动态解析JSON的代码,边界不清反而难维护。
4. Vue前端:动态表单渲染与待办操作中心
4.1 动态表单:用配置JSON驱动页面生成
前端这部分我是用Vue实现的,页面没有针对请假、报销各写一套表单,而是利用流程定义里的form_schema动态渲染。发起审批页面在挂载时请求流程详情,拿到form_schema就循环生成表单。
模板结构大概是这样的:
<template> <el-form :model="form" :rules="rules" ref="formRef" label-width="100px"> <el-form-item v-for="field in formSchema.fields" :key="field.prop" :label="field.label" :prop="field.prop" > <el-input v-if="field.type === 'text' || field.type === 'textarea'" v-model="form[field.prop]" :type="field.type" /> <el-input-number v-if="field.type === 'number'" v-model="form[field.prop]" /> <el-date-picker v-if="field.type === 'date'" v-model="form[field.prop]" /> </el-form-item> </el-form> </template>校验规则动态生成,前端把所有required为true的字段逐个加入rules对象:
const rules = {}; formSchema.value.fields.forEach(field => { if (field.required) { rules[field.prop] = [{ required: true, message: `${field.label}不能为空`, trigger: 'blur' }]; } });这套方案的直接收益是:后端加一种新流程,前端不用加一个页面。流程类型从3种扩展到10种,前端代码量是零增长。提交时把form里的数据对象原样JSON.stringify后作为form_data传给后端,后端原样入库,读取时原样返回,中间的字段语义完全由配置决定。
4.2 待办操作面板:详情、时间线、审批按钮
待办中心是整个系统最核心的交互页面,布局上我用的是左列表右面板的结构:左侧是待办表格,右侧是点击某条待办后打开的详情抽屉。
详情抽屉里有三块内容:
- 业务表单预览:把
form_data解析后,再次按form_schema的字段顺序渲染成只读文本。请假天数、开始日期、请假原因,一目了然。 - 审批时间线:从
flow_history接口拿历史记录,渲染成时间线组件。谁在什么时间通过了哪个节点、写了什么意见,一条条列出来。 - 审批操作区:一个意见输入框,两个按钮——“通过”和“驳回”。撤回按钮只出现在“我的申请”页面,发起人未处理完之前可以主动撤回。
审批操作的接口约定很简单:
export function handleTask(data) { return request({ url: '/api/task/handle', method: 'post', data }); }data里携带taskId、action(PASS或REJECT)、comment。操作成功之后前端做两件事:刷新待办列表、清空当前选中的详情状态。这里有一个体验上的细节:审批完成后要弹出成功提示,但是不要再用alert这种原生弹窗,用组件库的消息组件,观感会好很多,也更符合管理系统的使用习惯。
4.3 登录态管理:路由守卫与请求拦截的配合
整个系统的登录态用Token管理,后端登录接口成功后颁发一个带过期时间的Token,前端存到localStorage。系统能顺畅跑起来,离不开两段代码:路由守卫和请求拦截。
路由守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); } else { next(); } });请求拦截器:
service.interceptors.response.use( response => { const res = response.data; if (res.code === 401) { localStorage.removeItem('token'); router.push('/login'); return Promise.reject(new Error('登录已过期')); } return res; }, error => { return Promise.reject(error); } );这组配合的效果是哪怕用户很久没操作、Token过期了,也不会在看列表时看到一堆堆的报错数据,而是自动回到登录页重新登录。需要注意的是:路由守卫只能控制页面跳转,控制不了接口访问,真正的权限校验必须放在后端拦截器。前端的保护属于“体验层”,后端的拦截属于“安全层”,两层都做才完整。
5. 开发中真实踩过的四个坑:从现象到修复
这个项目开发过程中最值钱的并不是那些顺利写完的功能,而是踩坑之后总结出来的一堆经验。挑四个最有代表性的,把完整排查链条写出来,大家写类似系统时可以提前规避。
5.1 并发任务被重复审批,状态被覆盖
有一天测试妹子说:她把浏览器开成两个标签页,同时点一个任务的“通过”,结果系统里生成了两条审批历史,而且任务状态没有任何变化。我一开始以为是前端按钮没做节流,加了loading之后问题还在。
排查后定位到根因在SQL语句上。最早我用的是MyBatis-Plus的updateById方法,这个方法的更新条件只有一个主键ID,完全不关心任务当前状态。两个并发请求同时执行,都读到PENDING状态,然后先后执行更新,相当于把同一个任务处理了两遍。
修复方式就是把更新SQL改成带状态条件的乐观更新:
@Update("UPDATE flow_task SET status = 'DONE', comment = #{comment}, " + "handle_time = NOW() WHERE id = #{taskId} AND status = 'PENDING' AND assignee_id = #{operatorId}") int finishTask(@Param("taskId") Long taskId, @Param("operatorId") Long operatorId, @Param("comment") String comment);影响行数是0,就抛业务异常“任务已被处理”。从此再多的并发点击也只会有一个请求成功。这条经验换到任何状态类更新都适用——更新前必须带上当前状态作为条件。
5.2 审批完却卡住了,下一节点取不到
功能上线测试时遇到一个诡异的现象:某个流程审批到第二个节点,点“通过”之后整个实例直接不见了,控制台报空指针异常。这个问题的排查链路让我花了不少时间。
先看异常栈定位到resolveApprover方法,发现里面查nextNode返回了null。然后再去看flow_node表数据,发现一个流程里有两条节点的sort都是2。原来是我在配置节点的时候,用SQL批量插入,排序值写重复了。而selectNextNode的实现是WHERE def_id = ? AND sort > ? ORDER BY sort LIMIT 1,两个sort=2的节点,排序结果每次可能不同,导致有时能取到下一个节点有时候取不到。
修复分两层:第一层,把查询排序改为ORDER BY sort, id LIMIT 1,确保排序值相同的情况下依然有稳定顺序;第二层,在节点新增接口做一个校验,同一个流程里不允许插入重复的sort值。这个坑告诉我,手工维护“链表关系”时,所有用来定位的字段都必须保证唯一性。
5.3 Token过期后重复弹登录框
某次联调时,前端的请求日志里出现了一连串401错误,同时浏览器弹了好几次登录框。背景是请求拦截器里写了401就跳转登录页的逻辑,但同一时刻页面会并发发起多个接口请求,每个请求的响应都是401,于是每个请求都执行了一次跳转。
修复思路是加一个重定向标记,让401处理只执行一次:
let isRedirecting = false; if (res.code === 401) { if (!isRedirecting) { isRedirecting = true; localStorage.removeItem('token'); router.push('/login'); setTimeout(() => { isRedirecting = false; }, 2000); } return Promise.reject(new Error('登录已过期')); }这个问题的本质不是“Token过期”本身,而是多个异步任务共享同一个全局副作用时的竞态问题。以后凡是遇到“某个条件触发一次全局操作”的场景,都可以照这个思路加一个防重入标志。
5.4 待办列表N+1查询,越查越慢
系统跑了两周后,待办列表的响应时间从最初的几十毫秒涨到了接近一秒,用户开始有感知了。我打印MyBatis日志一看,一个装20条待办的列表接口,发了几十条SQL——先查任务表,再循环查实例、查用户、查流程定义。数据量小的时候看不出问题,但一旦待办变多,这就是灾难。
修复方案就是前面展示过的那条JOIN SQL,把所有关联表一次性查询出来,映射到预定义好的TaskVO对象里。动手之前顺便把所有Mapper接口查了一遍,凡是出现过“在循环里调Mapper”的地方全部重构成JOIN。这个经验其实很老套,但真的踩一遍才知道痛——动态列表接口最好从一开始就按JOIN来写,不要指望“数据量小没事”这句话永远成立。
6. 拿到源码怎么跑起来:环境、配置、启动、扩展
最后这部分写给第一次接触这个项目的开发者。源码放到IDEA和VSCode里就能编译,但有几个配置点如果不注意,启动时会浪费不少时间。
6.1 环境版本和目录结构
推荐环境组合如下:
| 组件 | 版本要求 |
|---|---|
| JDK | 1.8及以上 |
| Maven | 3.6及以上 |
| MySQL | 5.7或8.0 |
| Node.js | 14及以上 |
| 前端脚手架 | Vue CLI生成的项目,Vue版本2.6 |
后端目录是常规的Spring Boot分层结构:controller、service、mapper、entity、common。前端目录也标准:views、router、api、utils。流程引擎核心代码集中在service/engine包里,想理解流转逻辑,只需要看懂ProcessEngineService和对应的Mapper。
6.2 数据库初始化与配置文件修改
源码包里的sql目录下有一个初始化脚本,里面包含建库语句、建表语句和初始数据。执行方式:
mysql -uroot -p < flow_manage.sql脚本里默认创建了flow_manage数据库,包含前面提到的全部数据表,以及几个测试用户、流程定义和节点配置。这个脚本必须一次性完整执行,不要只执行半截,否则流程定义和节点表数据对不上,启动后会出现“查不到起始节点”的报错。
后端唯一必改的配置文件是application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/flow_manage?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的数据库密码特别注意serverTimezone这个参数,MySQL 8.0如果没有显式指定时区,经常会报时区相关的连接错误。
6.3 前后端启动步骤与常见报错
后端启动两种方式任选其一:
mvn spring-boot:run或者先打包再运行:
mvn clean package -DskipTests java -jar target/flow-manage.jar前端先安装依赖,再启动开发服务器:
npm install npm run serve前端默认端口是8080,后端默认端口也是8080,冲突的话需要改前端代理配置。开发环境下我在vue.config.js里配好了一个代理,把/api开头的请求转发到后端地址,这样就不需要处理跨域了。
常见启动报错基本就三种:
- 数据库连不上:控制台提示
Access denied for user。优先检查账号密码,其次检查MySQL端口。 - 表不存在:说明初始化脚本没有执行成功,重新执行一遍脚本。
- 端口被占用:执行
netstat -ano | findstr 8080找到占用进程,要么关掉进程,要么改掉配置里的端口。
6.4 加一种新流程类型,10分钟就能完成
这是这套系统最实用的扩展方式。假设现在要加一个“加班申请”,操作如下:
第一步,在flow_definition表插入一条记录,定义def_key=overtime,form_schema写上加班日期、加班时长、加班原因这些字段。第二步,在flow_node表插入三条节点记录:起始节点、部门主管审批节点、人事备案节点,sort依次为1、2、3。第三步,给测试用户配置好human_resource角色。
做完这三步,不需要重启后端、不需要改任何代码,打开前端发起审批页面就能看到新流程。这个扩展方式我在README里单独写了一章,凡是问“系统支持自定义流程吗”的人,直接把这段操作演示一遍,比任何解释都有说服力。
项目做下来,我最大的体会不是CRUD写得多熟练,而是状态机的每一个分支都必须提前想清楚。什么时候允许通过、什么时候允许驳回、什么时候允许重提、重提之后旧任务怎么处理,这些规则没有多复杂,但漏掉任何一条,线上就会出很诡异的Bug。把流程引擎的代码写得足够集中,让每一个状态转换都有明确的入口和出口,这套工作流系统才算真正立住了。如果你也在做类似的项目,建议从表结构开始就坚持“表单与流程分离”这条路,后面每一步都会轻松很多。