手头这个项目是给一家中型制造企业做的内部资金审批系统,标题就是“全栈项目实例(资金分配+工作流程)”。核心需求很简单:财务发起一笔资金分配,按部门、按项目拆清楚,然后走审批流,领导逐级审,审完自动入账,全程留痕可追溯。技术栈定的是 vue3 + TypeScript + Element-Plus 做前端,后端 Spring Boot + Mybatis-Plus,数据用 MySQL。
这类系统听起来不复杂,但真正做起来会发现“资金分配”和“工作流程”这两个词背后藏了大量细节:拆分的幂等性怎么做、审批节点的状态怎么流转、并发场景下会不会超发、前端怎么保证多人同时操作不脏数据。这篇文章把整个从零到上线过程中的设计思路、核心实现、踩过的坑都梳理一遍,重点是讲清楚每一步“为什么这么做”,而不只是贴代码。
适合正在做企业级管理系统、想了解全栈实战细节的开发者参考,也适合被领导安排“快速搞一套审批系统”但还没理清头绪的前端或后端同学。文章会从需求拆解开始,一路讲到部署上线,尽量把实际项目里那些文档上不写的东西说透。
1. 项目全景与关键设计思路
1.1 这个系统到底要解决什么问题
先说业务场景。企业内部的资金分配,通常不是财务直接打款,而是先做预算计划:某个季度有 500 万要分配到三个事业部,每个事业部分到多少,再由事业部往下拆到具体项目。每一层拆分都有成本中心负责人确认,确认完才能执行付款。整个过程涉及多角色协作,并且每一步操作都要留下审计记录。
如果不用系统,全靠邮件和 Excel 流转,会出现几个典型问题:版本混乱(一个人改了金额另一个人不知道)、审批状态不透明(领导不知道卡在哪个环节)、数据无追溯(出问题查不到是谁在哪天改的)。这个项目的目标就是用一套全栈系统把这些环节线上化,让资金分配从“线下协调”变成“系统驱动”。
所以项目本质上不是一个 CRUD 脚手架,而是一个带着业务规则的管理系统。这也是选技术栈时要考虑的点:需要稳定的类型约束(钱不能算错)、需要丰富的企业级组件(表格、表单、审批流),还需要后端能快速迭代业务逻辑。
1.2 技术选型的理由和取舍
前端选 vue3 + TypeScript + Element-Plus,是当下企业管理系统里非常成熟的组合。vue3 的组合式 API 让逻辑复用变得直观,TypeScript 给复杂的数据结构加了约束,Element-Plus 提供完整的企业级组件库,表格、弹窗、表单校验开箱即用。这套组合的好处是团队上手成本低、社区资料多、招聘也容易,踩到什么坑基本都能搜到解决方案。
后端 Spring Boot + Mybatis-Plus 更是经典。Spring Boot 负责把分层架构串起来,自动配置省掉大量配置工作,Mybatis-Plus 在 Mybatis 基础上做了增强,单表 CRUD 几乎不用写 SQL,分页插件、乐观锁插件都是现成的。对于这种业务以查询、状态流转为主的系统,能大幅缩短开发工期。
理解为什么不用更“新潮”的技术栈,比如前端用微前端、后端用微服务,是因为这个项目的规模和团队能力决定了越简单越好。一套单体应用 + 清晰的模块划分,足够支撑未来两年业务增长。过度设计是企业内部系统的头号杀手。
1.3 整体架构与数据流向
架构上分三层:前端展示层、后端接口层、数据存储层。前端启动后从后端拉取用户权限信息和待办数据,用户发起资金分配时填写表单,前端做第一轮校验(金额格式、必填项),提交后后端做第二轮校验(余额是否充足、是否有重复提交),校验通过则生成分配单并创建审批流程。
数据流向注意几个关键点:资金分配单创建后,金额立即“锁住”但不能真正扣减,要等审批全部通过才进入入账操作;审批人审批时只更新任务状态,不直接动资金数据。这种“先占额度后落账”的设计能避免审批过程中资金被其他单子占用。
整个项目按模块划分,后端源码里分为 controller、service、mapper 三层,前端按照 views 目录组织页面。目录结构清晰、职责单一,这是全栈项目后期维护能否省心的基础。
2. 资金分配核心模块设计与后端实现
2.1 资金分配单的表结构设计
资金分配这个核心模块,表设计决定了后续所有逻辑的复杂度。首先是一张主表fund_allocate,存分配单的基本信息:单号、总金额、申请人、申请部门、状态、创建时间。然后是一张子表fund_allocate_detail,存每一笔具体分配(比如给 A 部门 120 万、B 部门 80 万)。最后是一张关联审批流的表fund_approval_record。
字段设计上有几个容易被忽略的坑。金额字段一定要用 BigDecimal 不要用 Double,用 MySQL 的decimal(15,2)类型,Java 里对应 BigDecimal,避免浮点数精度丢失。业务单号不要用数据库自增 ID,要单独生成带业务含义的编号,方便对账,也避免单号泄露业务量。每个表都要带上version字段,这是给后续乐观锁用的。
详细说一下业务单号生成。我采用的是“场景前缀 + 年月日 + 四位流水号”的规则,比如FD202506120001,FD 表示 Fund Distribution,20250612 是日期,0001 是当日流水。生成逻辑是利用数据库唯一索引防止并发重复:先插入一条空单拿到 ID,再用 ID 参与流水号计算。直接查当前最大单号再 +1 的方式在高并发下会出问题。
2.2 创建资金分配单的完整流程和代码逻辑
创建分配单的后端 Service 逻辑是整个模块的重心。步骤如下:先做参数校验,检查申请金额是否大于 0,分配子项金额之和是否等于申请总金额,这两项不相等直接拒绝,因为系统里资金必须每一分钱都有去向。然后生成单号、保存主表和子表,最后创建审批流实例。
这里涉及一个事务问题:保存主表、保存子表、创建审批流实例,这三个操作必须在同一个事务里,任何一个失败都要整体回滚。用 Spring 的@Transactional注解在最外层方法上即可。但要注意,如果事务内外还有异步操作(比如通知审批人),不能让它在同一个事务里执行,否则事务未提交就发通知,审批人打开系统看到的是旧数据。
Mybatis-Plus 在这个模块的主要作用是简化 CRUD:saveBatch批量保存子表数据、selectById按主键查询、updateById更新状态。分页查询用内置分页插件PaginationInnerInterceptor,前端传当前页码和每页条数即可,不用手写 limit 语句。
2.3 资金额度的并发控制方案
资金分配系统最怕的是“超发”:剩余可用额度只剩 100 万,两个人同时各提交一笔 80 万的分配申请,如果并发控制没做好,两单都成功,资金对不上。解决这个问题,业界常见方案有三种:数据库悲观锁(select for update)、乐观锁(version 字段)、Redis 分布式锁。
这个项目里用的是 Mybatis-Plus 的乐观锁插件。逻辑是:在更新资金余额前先读出版本号,执行 update 时在 where 条件里带上版本号,如果版本号对不上说明别人已经改过,update 影响行数为 0,由代码判断后重试或报错。实现方式很简单,配置一个乐观锁拦截器,实体类的版本字段加@Version注解。
举个实际例子。如果只用乐观锁,极端高并发下仍然会有问题,因为两个人同时读到版本号 3,第一个人更新成功把版本变成 4,第二个人更新时 where version=3 查不到记录了,直接返回 0。此时要给用户一个友好提示“操作太频繁,请重试”,而不是直接报系统异常。实际验证下来,对内部管理系统来说并发量不会特别大,乐观锁已经足够,不需要引入 Redis 增加部署复杂度。
2.4 资金分配单的状态机设计
资金分配单不是“创建了就完了”,它有一个生命周期:草稿、待审批、审批中、已完成、已驳回。这个状态机设计得好不好,直接决定后面审批流逻辑写起来顺不顺。
我采用的是“独立状态字段 + 显式流转”的方式,不对状态字段做复杂的表驱动,而是用代码里的常量类定义所有状态,再在 Service 里写 clear 的状态流转方法。状态机的一个重要原则是:状态的改变必须伴随操作事件,并且要记录审计轨迹。fund_approval_record表就是专门干这个的,每一条审批记录包含操作人、操作时间、动作类型(提交、通过、驳回)、备注。
状态机设计初期是最容易稀里糊涂的,建议画一张简单的状态流转图:草稿只能提交、审批中可以逐级批或驳回、审批通过则进入已完成、驳回则回到草稿或直接终止。把这四个状态和对应操作理清楚,后端的每个接口就只需要处理当前状态允许的操作即可,非法操作直接拒绝。
3. 工作流程模块的建模与流转控制
3.1 审批流的数据模型设计
工作流程是这个项目的第二个重头戏。审批流本质上要回答三个问题:这个单子需要谁来审、按什么顺序审、每个人审完了下一步给谁。数据模型上,我用三张表搞定:approval_flow存流程定义(这个单子的审批链一共几个节点)、approval_task存当前待办任务(现在轮到谁审)、approval_history存已经完成的审批记录。
流程定义在创建资金分配单时生成。因为不同金额的单子审批层级不同,比如 50 万以下部门经理批就行,50 到 200 万需要加上财务总监,200 万以上还要总经理批。所以创建分配单时要根据金额动态生成approval_flow节点列表,而不是所有单子固定同一个流程。
这里有个设计经验:不要把审批节点存在主表的 JSON 字段里,虽然看起来方便,但要统计“某个审批人一共经办了多少单”时,SQL 写起来极其痛苦。把流程节点拆成独立表,每个节点带order_no排序号、approver_id审批人、status当前状态,天然支持复杂查询。
3.2 审批流转的核心实现
审批的核心动作是:“当前节点更新状态 + 创建下一个节点的待办任务 + 写审批历史”。这三个操作同样要在一个事务里完成,不然会出现“审批记录写了但待办没生成”的数据不一致。
流转逻辑写成一个统一的方法,传入当前任务和审批结果(通过或驳回)。通过时,查询下一个节点,如果存在则把任务状态改为已通过并创建新任务,如果不存在说明整条审批链结束,更新主表状态为已完成。驳回时,直接把当前任务状态改为已驳回,主表状态改为已驳回,整个流程终止。
不要小看“统一流转方法”这件事。我最初是每个审批接口里各自写状态更新,结果加节点时改动特别大,而且非常容易漏更新某个表。后来重构成都走同一个 Service 方法,不管审批人是在 PC 端还是移动端,入口不一样但核心逻辑完全一致,后续维护就轻松多了。
3.3 超时提醒与任务催办功能
审批流有个很现实的痛点:单子卡在某个审批人那里不动了。业务部门催财务、财务催领导,效率极低。所以系统做了一个简单的催办机制:审批任务创建时会记录create_time,系统每天定时扫描超过 48 小时仍未处理的待办任务,给审批人发送提醒通知,同时给发起人发送“单子可能会被搁置”的警示。
定时任务用 Spring Boot 的@Scheduled注解就能实现,配置一个 cron 表达式每天凌晨跑一次。注意定时任务里不能直接依赖异步通知接口,因为通知服务可能不稳定,我的做法是把需要通知的任务先写入一张notify_queue表,另一个消费者线程去处理,保证通知不会因为某个外部接口超时而丢数据。
这个功能可能要排到后期迭代里,但如果业务方提出来了,设计上留好位置就行。approval_task表上有remind_count和last_remind_time字段,分别记录提醒次数和最后提醒时间,防止每次都把几十条未处理任务全量提醒一遍,把审批人烦到直接提离职。
4. 前端 Vue3 + TypeScript + Element-Plus 的落地细节
4.1 项目初始化与目录结构规划
前端项目用 Vite 做构建工具,创建命令一条搞定,但初始化之后要做的第一件事不是写业务代码,而是规划好目录结构。我把 src 下面按功能拆成 api、assets、components、router、store、types、utils、views,这个划分在项目变大后显得特别重要。
types 目录放 TypeScript 接口定义,比如FundAllocate.ts里定义分配单主表信息、子表明细、审批任务结构。为什么要单独维护 types?因为在和某人并行开发时,只要接口定义同步好了,前端逻辑可以完全脱离后端独立开发,用 mock 数据也能把页面写完。等后端接口好了,替换掉请求函数前缀就行。
对于组件封装,我把全局通用能力做到 components 里:分页表格组件、表单弹窗组件、状态标签组件。这些组件都用 defineProps + defineEmits 的方式定义好对外接口,内部逻辑自包含。比如封装一个SearchTable组件,搜索区、工具栏、表格、分页都搁一起,业务页只需要传 columns 和数据请求函数。
4.2 审批任务列表和资金分配表单的实现
前端最核心的两个页面:待办审批列表和资金分配表单。待办列表用 Element-Plus 的el-table渲染,列包含单号、申请人、金额、当前节点、发起时间,操作列根据任务状态动态渲染按钮。审批弹窗里,审批人可以看到申请详情(主表信息 + 子表明细),点击通过或驳回时输入审批意见,意见必填。
资金分配表单是另一个复杂场景:动态增删子项。用户可能添加 3 个分配行,也可能添加 10 个,每一行的部门、金额、项目名称都要填。表单校验上分两层:每行校验非空、金额大于 0、格式正确,整体校验是子项金额之和必须等于总金额。这些校验规则用 Element-Plus 自带的规则机制加上自定义 validator 就能实现。
需要特别注意的坑:动态增删表单项,v-for 绑定的 key 不要用数组索引,否则删除中间行时会导致表单状态错乱。我用的是每一行维护一个唯一的 id,删除时按 id 过滤,新增时生成新 id,这样表单数据始终和 DOM 元素正确对应。
4.3 前端权限控制和路由守卫
企业内部系统最怕“任何人打开都能看到所有人的数据”。前端通过路由守卫和按钮级别控制实现基础权限:登录后拿到用户的角色信息,存到userStore,路由守卫里根据角色和当前访问路由的 meta 字段判断是否有权进入,没权限则跳转首页。
按钮级别用自定义指令v-permission实现,传入角色编码,指令内部检查当前用户角色是否在允许列表中,不在则移除按钮 DOM。比如资金分配单的操作栏,管理员能看到“删除”按钮,普通审批人只能看到“审批”按钮。
这里有个经验:前端权限控制只是用户体验层面的优化,真正的数据权限必须在后端接口做好判断。前端隐藏按钮只影响界面,如果后端不加校验,懂技术的人直接调接口就能越权操作。所以我在所有 Service 的方法里都显式判断了操作人身份和目标单据的归属,保证越权请求拿到的是“没有权限”而不是数据。
4.4 和 Element-Plus 组件库配合的体验细节
Element-Plus 的功能很全面,但默认行为和业务经常有差距,需要做定制。比如el-table的长列表性能,如果一次渲染 500 行以上会有明显卡顿,通常是后端加分页解决,所以这次列表全部用后端分页。再比如金额列的对齐方式,默认左对齐非常影响阅读和核对,我在列配置里显式设置align="right",这是做财务相关页面的基本素养。
表格里状态列我会用el-tag渲染,不同状态显示不同颜色。这个功能我抽成了一个StatusTag组件,传入状态码和一个映射配置对象,组件内部做颜色和文案映射。这样改状态颜色时只改一处配置。
提交按钮的防重复点击也很重要。用户网络慢时,点击提交后页面卡住,心急的人再点几下,后端就会收到多个请求。前端在提交按钮点完后立刻loading = true,同时禁用按钮,等请求完成或失败再恢复。后端再配合幂等性设计,双保险。
5. 前后端联调、接口约定与部署上线
5.1 接口设计规范与数据字典约定
全栈项目最耗时的往往不是单独写前端或写后端,而是前后端接口对不上。这个项目从开始就定了一批接口设计规范,主要几条:接口返回结构统一为{ code, message, data },code 为 0 表示成功,不为 0 表示各类错误码;分页接口统一传pageNum和pageSize,返回对象里带total和records;时间格式统一传字符串,格式为yyyy-MM-dd HH:mm:ss,不让前端自行处理格式化。
遵守这些约定后,前端的请求封装就非常简单:axios 拦截器里统一判断 code,不为 0 就弹出错误 message,为 0 时直接返回 data。业务代码里不需要到处写 try-catch 来处理业务异常,关注点全部集中到成功后的数据渲染上。
文档维护上,我们规定每周五必须把接口变化同步到在线文档里。很多人觉得写文档浪费时间,但等项目结束后新人接手或自己三个月后再看,文档是唯一能救命的东西。不用写详细,把每个接口的地址、入参、出参结构、特殊说明写清楚即可。
5.2 本地联调环境配置和 mock 策略
联调阶段最烦的是后端还没写完,前端页面已经写完,干等着没法看效果。我的做法是一个简单好用的 Mock 方案:不用额外服务,直接在 Vite 的代理配置上做文章。开发模式下给 axios base URL 设置一个本地前缀,然后在前端项目启动时注册一个本地 Node 中间件,用开发时写好的 mock 数据文件模拟后端接口响应。
当后端接口好了之后,要切到真实环境,只需要把所有 mock 数据文件里的接口地址逐个替换成真实代理地址。这个过程中最容易出问题的是字段对不上,接口定义成了applyTime,后端返回来的是apply_time,前端拿到 undefined 就渲染空。所以我在所有时间字段、金额字段上都做了统一约定,命名用驼峰,后端做字段映射,避免这个问题。
跨域问题即使在联调阶段也是最常见的拦路虎:前端 5173 端口、后端 8080 端口,直接请求会被浏览器拦截。我建议前端 Vite 代理配置里写好后端地址,比如/api前缀全部代理到http://localhost:8080,这样浏览器看到的都是同源,绕开跨域问题。生产环境用 Nginx 做一层反向代理,配置类似。
5.3 部署方案和配置管理
部署上没有引入太重的方案,服务器上用 Docker 跑一个 MySQL 容器、一个后端应用容器,前端构建后的静态文件放到 Nginx 里托管,同时用 Nginx 把/api路径反向代理到后端容器。
后端配置拆成三份:开发环境、测试环境、生产环境,用 Spring Boot 的 profile 机制管理,启动时通过--spring.profiles.active=prod指定。数据库密码等敏感信息不要直接写在配置文件里,用环境变量的方式注入,即便代码仓库泄露也不会直接暴露生产凭据。
构建命令上,后端执行mvn package -DskipTests生成 jar 包,前端执行npm run build生成 dist 目录。部署流程在初期是手工操作,后来业务稳定了再写成一个简单脚本:拉代码、编前端、编后端、停旧容器、启动新容器。没有引 CI/CD,因为团队规模没必要为了自动化而自动化。
5.4 日志规范与线上问题排查
“生产环境出问题找不到原因”是所有管理系统的通病,所以日志从第一天就必须规范。后端用 Logback 配置日志,按天滚动,保留 30 天。日志级别区分:业务操作记录(创建单、审批、驳回)用 info,接口入参统一记录;异常用 warn 和 error 级别。所有关键业务的日志里必须带上业务单号,排查问题时直接按单号 grep。
前端不直接打日志,但统一封装了前端错误上报:请求返回 code 非 0 时,拦截器会把接口地址和参数发到后端的日志接口,存到sys_operation_log表。线上用户反应“我提交总是报错”,后端一查日志,发现是哪一步出错、哪个参数不对,效率非常高。
日志排查有个小技巧:按时间范围加单号双条件查询,前端传递的用户反馈通常有时间区间,后端日志同时能定位到具体某一单,基本上五分钟就能找到问题根因。这个习惯在项目上线后帮助非常大。
6. 常见问题与排查技巧实录
6.1 并发提交导致的数据重复问题
测试阶段最容易复现的问题:两个测试人员同时操作,一个提交资金分配单,另一个也提交,后台查数据库出现两条一模一样的数据。排查一圈发现是前端按钮点击后 loading 只是按钮禁用,但相同参数请求因为浏览器缓存被第二次发出,后端收到两次。
解决分两层。后端在 Service 入口处做幂等性判断:先查询同一申请人、同一天、同一总金额、同一状态的分配单,如果存在且创建时间在几秒内,直接返回已有单号。前端也做了优化:提交成功后立即跳转列表页,从根上切断重复点击的可能。实际效果是实现幂等校验后,重复提交的记录基本为零。
6.2 事务不回滚的原因排查
有一次测试发现创建分配单时子表保存成功了但主表状态更新失败,按道理事务应该回滚,但数据却留在了数据库里。查代码发现是子表保存用了saveBatch方法,而主表和子表的 Service 方法不在同一个事务代理里调用,内部调用绕过 Spring 的事务切面导致部分操作没被纳入事务。
解决办法是在入口 Service 的公有方法上标注@Transactional(rollbackFor = Exception.class),并确保事务方法从外部类入口进入,不要在类内部直接调用自身方法。排查事务问题最简单的方法:临时在事务方法里抛个运行时异常,看数据库数据是否全部回滚,是则说明事务生效。
6.3 前端金额计算精度丢失
某个版本的前端汇总金额出现差异,总数是 100 万,子项加起来是 99.999999999。查了很久,发现是 JS 的 Number 类型对浮点数的存储缺陷:1000000.01 + 999999.99 这类计算会得到不精确的结果。
这个问题的根治办法是前端所有金额计算全部用整数分来算,或者用专门的高精度计算库。我的做法是把金额前后端统一用“分”存储,显示时才转为元,前端所有计算基于整数,确保不会出现浮点误差。后端数据库也存分为整数类型,账目对不齐的问题直接消失。这个改动需要前后端联合约定,改起来工作量适中,但长期收益很大。
6.4 审批流程卡住的排查方法
上线一个月后,业务反馈有一张单子显示“审批中”但一直没有动静,审批人那边看不到待办。排查链路是:先查approval_history看最后一个节点是谁审批的,审批时间是否正常;再查approval_task看当前节点是否有任务记录;最后查approval_flow看流程是否正常推进到当前节点。
最终定位是某次审批操作时,当前节点状态更新成功,但“下一个节点任务创建”这个动作因为数据库插入报错没有完成,而且这个异常被上层 catch 吞掉了,只打了 error 日志没有让事务回滚。修复方法是在审批流转方法里不吞掉关键异常,同时增加一个夜间巡检脚本,扫描所有“审批中”但超过 24 小时没有新审批记录的单子,发提醒给系统管理员人工介入。这个机制上线后,再也没有出现过单子莫名卡住的情况。
7. 项目上线后的复盘与扩展建议
7.1 从业务视角看系统交付的实际效果
系统上线后的效果从几个指标可以直接看到:资金分配单的平均审批周期从原来的 3 天缩短到不到 1 天;财务月底对账时间从两天减少到半天;经办人问“单子到哪了”的频率基本降为零。这些数字比任何技术指标都有说服力,系统性解决业务痛点的价值就在这。
另外一个意外的收获是审计效率提升。以前财务审计时要翻大量纸质审批单,现在直接在系统里按时间范围、金额范围、审批人查记录,所有操作历史一目了然。系统上线三个月后,审计人员已经把它当作首选工具。
7.2 下一步可能的扩展方向
如果这系统要继续演进,有几个方向值得考虑。第一是和财务核算系统对接,审批通过后的数据自动生成记账凭证,省掉人工二次录入。第二是引入数据可视化和报表驾驶舱,管理层不用等财务周报,自己打开系统就能看各事业部资金分配和执行情况。第三是把审批流程做成可视化配置界面,让业务人员自己调整流程节点,而不是每次改流程都要发版升级。
扩展时要注意一个原则:不要一上来就引微服务和中间件,当前单体和 MySQL 完全够撑几年。所有架构调整应该由实际业务指标驱动,比如单表数据过千万、查询出现明显变慢时再考虑分表或引进更复杂的技术方案。
7.3 我实际使用过程中的几点体会
做这个项目最大的体会是:全栈项目真正难的地方不是某一门技术,而是把业务规则贯穿到从数据库到页面的每一条数据流上。资金分配核心的“子项之和等于总额”“审批通过才扣减余额”“每个操作都留痕”这些规则,分散在前端校验、后端事务、数据库约束各个层面,任何一个环节漏了都会在后续出问题时暴露出来。
另一个体会是,技术栈选“无聊”的其实是优点。这套 vue3 + Spring Boot 的组合鲜有惊艳之处,但稳定、资料多、同事好接手,结果就是项目交付顺畅,后续维护也没人喊苦。企业级开发不是追求技术领先,而是追求用最稳妥的方式解决问题,这个思路贯穿始终。
最后想分享一个小技巧:所有状态字段不要用没有约束的数字或者字符串,枚举值一定要写在公共常量类里,前后端定义同一份值。项目里就吃过状态值记不住的亏,后来所有状态值集中到constants文件里并加上注释,前后端共用一份映射,改起来也容易得多。如果你也要做类似系统,这个习惯建议从一开始就养成。