资金分配+工作流全栈实战:Vue3+Spring Boot状态机设计与工程落地
“资金分配工作流程”这八个字看着简单真正动手做一套全栈系统时你会发现里面的设计点密集得像挤早高峰。前端选了 vue3 TypeScript Element-Plus后端选了 Spring Boot Mybatis-Plus最后要落地的不是一个“增删改查面板”而是单据创建、金额明细、角色审批、状态流转这一整条业务链。我在两周内把一个可运行的项目从空目录拉通过程中经历了数据库字段设计反复改、前端类型定义和后端模型对不上、并发场景下审批状态乱跳等一堆实际工程问题。这篇文章不跟你讲框架广告就按我在实操项目里踩过的路把这个全栈项目的拆解思路、核心实现、常见坑位一次讲透。1. 项目整体设计与技术选型拆解1.1 为什么选择这套全栈组合先说技术选型的逻辑。资金分配系统的本质是“状态 金额 节点”核心难点不在某一个框架本身有多强而在于前后端是否能形成一套干净可扩展的协作模型。Vue 3 的 Composition API 配合 TypeScript对复杂表单和状态模块的组织能力比旧写法舒服太多尤其在多人协作时interface 约定好之后组件内部基本不会出现“这个字段哪里来的”这种低级混淆。TypeScript 在编译期拦截掉字段拼写、空值判断这些低级错误让后期改结构时不用靠肉眼一个个找。Element-Plus 是 Vue 3 生态里最成熟的后台组件库表格、表单、步骤条、弹窗这些资金分配和审批场景的高频组件都能开箱即用几乎不用在基础设施上重复造轮子。后端用 Spring Boot 是因为它把依赖注入、配置管理、请求处理和周边生态都盘活了Mybatis-Plus 则在单表 CRUD 上省掉大量手写 SQL 的时间同时保留 XML 自定义复杂查询的口子。这个组合很老套但正是这种稳才适合业务逻辑比技术炫技更重的项目。我见过一些团队为了“先进”引入微服务、工作流引擎结果一个资金分配系统拆出五个服务开发效率不升反降。实际经验告诉我项目初期尽量用团队熟悉且覆盖面广的栈先把业务跑通再考虑架构升级。这次选型最核心的理由是“低成本、高可控、好招人”不是因为它热门而是因为它让项目交付足够有把握。1.2 核心业务实体与领域建模资金分配加工作流程最终业务域抽象出来只有四大实体资金主单、资金明细、审批节点、审批记录。主单代表一次申请动作比如“某部门一季度预算申请”或“某成本中心项目拨款”它会带上申请人、金额、状态和当前处理角色明细是主单下面的金额拆分行描述钱具体分给谁或者用于什么项目审批节点则定义这笔单子要经过哪些角色的审核审批记录就是每次提交、同意、驳回留下的痕迹方便追溯。这四个实体的关系并不复杂主单一对多明细主单一对多审批记录审批节点则独立维护一套配置数据。比如你可以配置“金额小于十万只需部门负责人审核超过十万必须财务复核”资金申请单就会在流转时根据配置动态决定跳转到哪个审批关卡。这样建模的好处是领域清晰、表结构直观后续即使增加新的审批类型也只改节点配置而不用重构主表和明细表。我在设计时特意把审批记录和主单分开而不是在主单里堆一堆“审批人、审批意见、审批时间”。原因很简单一次申请可能被驳回再提交提交后重新审批如果都在一张表里存字段就会出现大量冗余列和搞不清楚的版本问题。分开保存记录后主单只维护当前状态整个过程由流水表记录查询和回放都很容易。1.3 轻量状态机而非重流程引擎审批流转最怕背上引擎包袱。很多开发一听到工作流程第一反应就是接入那种专门的工作流框架。但回到这个资金分配场景审批路径基本是“草稿→提交→部门审批→财务复核→拨付完成”分支条件并不多。我最终选择自己实现一个轻量级状态机状态枚举 动作方法 权限校验。比如“提交”这个动作只允许从草稿状态到待审批状态“部门审批”只允许待审批状态进入部门审批中或者驳回通过所有节点后进入已完成。同时每个动作都在审批记录表写入一条日志状态流转的可追溯性就有了。这种设计的可维护性非常明显状态流转逻辑集中在一个服务类的几个方法里而不是散落在 controller 或页面。前端也只需要按当前状态判断显示哪些按钮不用理解底层流程怎么走两边都简单。2. 数据模型与工作流状态设计2.1 核心表结构与资金字段设计资金分配最怕金额字段出错。表结构里金额统一用DECIMAL(14,2)Java 侧用BigDecimal前端提交参数同样用字符串或由框架精细转换坚决避开float/double。这是所有资金类系统的基础红线。主表设计我给出关键结构供参考CREATE TABLE t_fund_apply ( id BIGINT AUTO_INCREMENT PRIMARY KEY, apply_no VARCHAR(32) NOT NULL COMMENT 申请单号, apply_name VARCHAR(128) NOT NULL COMMENT 申请名称, total_amount DECIMAL(14, 2) NOT NULL COMMENT 申请总金额, status VARCHAR(32) NOT NULL COMMENT 当前状态, cur_node VARCHAR(32) COMMENT 当前节点编码, creator_id BIGINT NOT NULL COMMENT 申请人ID, remark VARCHAR(500) COMMENT 备注, version INT DEFAULT 0 COMMENT 乐观锁版本, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_apply_no (apply_no) ) COMMENT 资金申请主表;明细表CREATE TABLE t_fund_detail ( id BIGINT AUTO_INCREMENT PRIMARY KEY, apply_id BIGINT NOT NULL COMMENT 主单ID, item_name VARCHAR(128) NOT NULL COMMENT 分配对象/项目名称, amount DECIMAL(14, 2) NOT NULL COMMENT 分配金额, remark VARCHAR(200) COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 资金分配明细表;审批记录表CREATE TABLE t_fund_approval ( id BIGINT AUTO_INCREMENT PRIMARY KEY, apply_id BIGINT NOT NULL, node_code VARCHAR(32) NOT NULL COMMENT 审批节点, action VARCHAR(16) NOT NULL COMMENT 动作: SUBMIT/AGREE/REJECT/RETURN, operator_id BIGINT NOT NULL, comment VARCHAR(500) COMMENT 审批意见, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT 审批流转记录表;这个设计并不复杂但它解决了三个核心问题金额精度、状态定位、流转可追溯。主单和明细分离后修改分配行时不会影响主单整体数据审批记录独立存储后任何时候都能拉出某一笔资金单的完整审批链路。2.2 状态机与动作矩阵我定义的状态枚举是草稿、待提交、审批中、已通过、已拒绝、已终止。考虑到实际业务里“部门审批”和“财务复核”是两个不同角色我在审批中状态下还维护一个当前节点编码就能知道卡在哪个环节。实际流转动作可以用一个状态机矩阵来管理当前状态可执行动作目标状态前置条件草稿提交待审批明细非空金额校验通过待审批部门审批审批中/已拒绝当前角色是部门负责人审批中财务复核已通过/已拒绝当前角色是财务已通过确认拨付已完成金额核对一致任意非终态终止已终止创建人或管理员操作这种状态矩阵不是纸面上画画而已代码里的核心方法都围绕它来实现。动作方法集中在一个审批服务里比如agree(applyId, operatorId, comment)它先加载主单校验状态是否符合预期再执行后续状态更新和记录写入。状态机的好处是让前端交互非常简单后端告诉你“当前状态”前端只决定“这个状态下允许哪个按钮出现”逻辑清晰不容易出现两边各写一套规则导致对不上账的情况。2.3 接口设计与幂等约束接口层面我拆成五组申请单 CRUD、提交申请、明细管理、审批动作、完成拨付。请求和响应用 DTO/VO 隔离不直接暴露数据库实体。涉及金额的请求参数全部用字符串或者明确指定类型避免 JavaScript 大数精度问题在接口层面直接炸开。同时我会做三个层面的幂等保护。第一申请单号唯一由后端生成并落唯一索引前端重复点击提交时数据库唯一键直接拦截第二审批动作使用乐观锁版本号每次更新都带where version ?如果版本不匹配则提示“数据已被其他人处理”避免两个审批人同时操作同一笔单第三对于“确认拨付”这类更敏感的操作我会额外生成一次操作 token提交后在内存缓存里做短时间戳校验这个不算复杂但很有效。3. 后端核心实现步骤Spring Boot Mybatis-Plus3.1 项目快速初始化与基础配置后端工程我用 Spring Initializr 搭骨架依赖选 Web、Validation、Mybatis-Plus 和 MySQL Driver。进入pom.xml后两步最关键一个是引入 Mybatis-Plus 的mybatis-plus-boot-starter一个是配置分页插件。application.yml里值得注意的点包括数据库连接指定时区、开启驼峰映射、Mybatis-Plus 的逻辑删除和分页插件配置。spring: datasource: url: jdbc:mysql://localhost:3306/fund_flow?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这些配置看着啰嗦但每一条都能避免稍后代码里的隐形坑。比如map-underscore-to-camel-case没开数据库apply_no就映射不到 Java 实体里的applyNo所有查询全变成 null排查起来非常痛苦。开发者经常忽略这行配置结果花大量时间在空字段问题上。3.2 统一响应与全局异常处理我定义了一个ResultT响应类包含code、message、data三个字段。前端 axios 拦截器统一判断 code业务异常和校验异常都走全局RestControllerAdvice。为什么不用 Spring 默认返回结构因为默认异常结构不稳定前端拿到的错误信息乱七八糟还得每次特判。统一后前端可以一键弹出后端定义的错误提示后端则不用在每个方法里写 try-catch。全局异常处理里我会专门捕获三类异常参数校验异常、业务异常、兜底异常。业务异常的业务码可以先约定 4000 开头是资金模块、5000 开头是审批模块方便日志排查。实际开发中这个约定会被反复使用尤其是线上环境有人操作出错时一眼就能从错误码定位到部位。3.3 资金申请提交的核心流程创建一笔资金申请是一个典型的事务方法。操作步骤是生成申请单号、保存主单、批量保存明细、写入提交记录到这一步状态已经切到“待审批”。实现上要特别注意事务边界。因为这一步一旦中途出错主单和明细必须一起回滚不能出现主单建了明细丢了这种脏数据。我直接使用 SpringTransactional把整个创建过程包起来。明细批量插入用 Mybatis-Plus 的saveBatch但数据量不大时逐条插入也完全够用性能差异可以忽略。核心片段Transactional public Long createApply(ApplyCreateDTO dto) { FundApply apply new FundApply(); apply.setApplyNo(generateApplyNo()); apply.setApplyName(dto.getApplyName()); apply.setTotalAmount(dto.getTotalAmount()); apply.setStatus(FundStatus.DRAFT.getCode()); apply.setCurNode(null); apply.setCreatorId(dto.getCreatorId()); fundApplyMapper.insert(apply); for (ApplyItemDTO item : dto.getItems()) { FundDetail detail new FundDetail(); detail.setApplyId(apply.getId()); detail.setItemName(item.getItemName()); detail.setAmount(item.getAmount()); detail.setRemark(item.getRemark()); fundDetailMapper.insert(detail); } return apply.getId(); }这里有句老实话很多初学者喜欢用saveBatch提升性能但一旦单条数据有错误批量操作的事务回滚行为很容易让人困惑。资金分配场景的明细行一般不超过几十条逐条 insert 配合统一事务已经足够稳定。3.4 审批流转与并发控制审批动作是资金工作流的核心也是并发风险最高的地方。例如部门审批和财务复核可能几乎同时操作同一笔单如果不做并发控制可能出现“部门已驳回财务又通过”的错乱结果。所以审批方法里我做三步加载最新主单、校验当前状态与节点、乐观锁更新。第三步是关键执行 SQL 时必须带版本号条件Transactional public void approve(Long applyId, String action, String comment, Long operatorId) { FundApply apply fundApplyMapper.selectById(applyId); if (apply null) { throw new BusinessException(申请单不存在); } // 校验当前状态是否允许该动作 checkActionAllowed(apply, action); // 乐观锁更新 int rows fundApplyMapper.updateForApprove(apply.getId(), apply.getVersion()); if (rows 0) { throw new BusinessException(数据已被他人处理请刷新后重试); } // 写入审批记录 FundApproval approval new FundApproval(); approval.setApplyId(applyId); approval.setNodeCode(apply.getCurNode()); approval.setAction(action); approval.setOperatorId(operatorId); approval.setComment(comment); fundApprovalMapper.insert(approval); }对应的 Mapper 方法用条件构造器实现int updateForApprove(Param(id) Long id, Param(version) Integer oldVersion);XML 或注解里就一条 updateupdate t_fund_apply set status?, cur_node?, versionversion1 where id? and version?。这里的乐观锁不是一个装饰品它是整个并发安全的地基。前端即使连续点了两次“通过”第二次更新行数也会是 0业务层直接抛异常不会产生状态错乱。4. 前端核心实现步骤Vue3 TypeScript Element-Plus4.1 工程初始化和请求层封装前端我用 Vite 初始化项目选 Vue3 TypeScript 模板然后安装element-plus和axios。Element-Plus 建议按需引入否则首屏包体积会明显偏大。项目里我把自动按需引入的插件配置好开发体验上基本无感。请求层是所有前端页面的地基。我封装了一个 axios 实例设置 baseURL、超时时间在请求拦截器里把 token 塞到 header在响应拦截器里统一处理ResultT的 code。当 code 不是 0 时直接ElMessage.error(message)并把 Promise 拒掉页面里不用到处写错误提示。这部分代码看起来基础但我在实际项目中见过太多页面直接axios.get然后各处 try-catch改接口路径时到处找。统一封装后新页面接接口只需要引入 api 模块中的方法可维护性高不少。4.2 资金申请页面的组件组合资金申请页面我用 Element-Plus 的el-form搭建总金额输入框单独放在头部明细行通过一个弹窗表格维护。明细编辑弹窗包含项目名称和金额两个核心字段关闭前做本地校验确保明细金额汇总后可以与总金额做一次前端提醒。页面层最担心的其实不是组件不会用而是类型和数据流混乱。我在types目录下这样定义接口export interface FundApply { id: number applyNo: string applyName: string totalAmount: string status: string curNode: string creatorId: number createTime: string } export interface FundDetail { id: number applyId: number itemName: string amount: string remark: string }这里的金额字段我用string而不是number目的是从根源上避免浮点精度问题。表单输入的是字符串传到后端再由后端解析成 BigDecimal前端展示也不需要转成数字去参与运算。如果需要加总明细金额我使用累加字符串再转 decimal 的方法而不是直接用 JS 浮点数相加。表格页展示时我用el-table结合后端分页。资金申请列表往往有大量数据一次加载全部在前端翻页的方案很快会把浏览器卡死。后端 Mybatis-Plus 分页插件负责 limit 查询前端保存 current page 和 size切换页码时重新请求。4.3 审批操作和状态步骤条审批页面的核心表达是“当前单子走到哪一步了”。Element-Plus 的el-steps组件很适合表达工作流进度我会把待审批、部门审批、财务复核、已完成这几个阶段映射成步骤条数组。根据apply.status和apply.curNode计算当前激活步骤用户一眼就能看清流程进度。按钮显示则用几个计算属性控制const canSubmit computed(() props.detail.status DRAFT) const canApprove computed(() { // 取决于角色和当前节点 return [PENDING, APPROVING].includes(props.detail.status) })审批操作区会提供“通过”“驳回”“退回修改”三个按钮点击后调用对应接口并刷新详情。这里有个细节弹窗或页面上出现审批意见输入框后我会在提交前做必填校验。因为很多业务场景强制要求审批意见必须填写后端也不能只允许空参数通过。我还会在详情页底部放一个审批记录时间线组件从t_fund_approval表拉取全部审批记录渲染成历史轨迹。不要小看这个时间线它是资金职责链追溯的核心出口也是客户最看重的一块展示。5. 常见问题与排查技巧实录5.1 金额精度“看起来对”却对不上资金系统最常见的 bug 就是金额误差。现象很迷惑明细加总和总数明明一样打印日志时却发现后端存储是 99.999999。这类问题几乎都来自前端或中间层把金额转成了浮点数参与计算比如Number(amount).toFixed(2)或者parseFloat后再次运算JavaScript 的二进制浮点会在某个小数位产生误差。我的规避方案非常模板化且有效前端金额全程用字符串存储和传递加总用整数分的方式即把元转成分后做整数运算需要展示时再转回元。后端和数据库统一 BigDecimal / DECIMAL。也就是说ErrorMessage 出现“金额不对”时先检查是否有浮点数参与运算这能解决 90% 的问题。5.2 并发场景下审批状态被覆盖有一次测试时我故意用两个账号同时对同一笔单点审批结果后点的审批覆盖了先点的操作记录表里出现两个同一节点的审批记录。原因是代码里最开始用的是“先查后更新”没有带版本号。改成乐观锁后问题立刻消失。具体做法上面提过更新时带上旧版本号行数为 0 就把后面那个请求挡住提示“数据已变化”。这种坑在开发环境不容易暴露但只要上线后并发一上来就会爆发。我的建议是资金类流程从第一版就必须带乐观锁不要等出事再造。5.3 列表页查询越来越慢随着数据量增长列表页会越来越卡。排查后发现两个主要原因一是前端把明细也一次性查出来导致主表查询加联表数据量一大就慢二是分页插件没有正确配置走的还是全表查询。我会做三层优化查询列表只查主表字段明细详情单独接口按需加载list接口使用 Mybatis-Plus 分页插件并且给apply_no、creator_id、status建立组合索引前端用分页而不是全量加载。这三步同时做列表接口能稳定在百毫秒级即使数据到几十万条也能接受。5.4 前后端字段对不上导致的“静默失败”还有一个很隐晦的坑是前端传到后端的字段命名不一致。比如后端实体用applyNo前端传了apply_noSpring 默认没开启 snake_case 映射时接口直接收到 null。这类问题不会报错只会表现为“明明填了内容保存后却丢字段”。我的核心排查方法是让前端的 TypeScript 接口和后端 VO 字段保持严格同源。可以把后端 VO 的 JSON 样例抽取成 TypeScript interface前端从接口响应里复制再调整杜绝凭记忆手写字段名。同时后端在 Jackson 配置里明确启用命名策略所有接口输出统一 camelCase前端也统一 camelCase不做任何临时转换从根上消灭不一致。6. 实操过程中的几点最终建议这个项目整体走完后我个人的体会是技术栈只是骨架真正决定项目成败的往往是那些细节约定。开发前先用半天时间把状态流转矩阵画清楚把数据表字段统一约定好后面编码的速度会快得超出预期反过来如果一上来就直接写页面和接口大概率会在中途反复改表结构最后代码越改越乱。另一个值得一提的经验是“垂直链路优先”开发法。不要先把前端所有页面写完再写后端也不要先把后端所有接口写完再碰前端。我的习惯是选一个最小核心流程比如“创建申请单→提交→审批通过”把数据库表、后端接口、前端页面完整地走通再在这个骨架上扩展其他功能。这样做的好处是每次迭代都能得到一个可验收的成果而且问题能尽早暴露在最小的代码范围内。最后再分享一个小技巧资金分配系统的所有操作我都在后端接口里追加了轻量日志包括操作人、操作时间、操作类型、变更前状态、变更后状态。这个日志平时看着没用真正出事故对账时就是救命稻草。别觉得它增加代码量花不了几个晚上但换来的排查效率是成倍的。如果你也在做类似的全栈资金流转项目不妨从这些角度先动手。