ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

企业级差旅管理系统落地实录:Java审批流与预算并发控制

2026/9/11 13:07:38 拓冰建站 浏览量
企业级差旅管理系统落地实录:Java审批流与预算并发控制 简介面向企业级差旅管理场景这份基于Java与前端技术的源码适合Java Web学习者、系统设计人员以及需要快速搭建差旅流程的企业开发者。系统围绕出差计划、行程管理、表单提交、审批流转、报销处理与出差记录统计等模块呈现了前后端结合的企业级业务系统设计思路。压缩包共206个文件约5.4MB核心代码包含60个Java类、14个JSP页面、14个JavaScript脚本、12个CSS样式、8个XML配置文件另含编译后的class文件、3个JAR包、21个PNG图片、3张JPG图片及属性文件等目录结构清晰便于按模块研读。系统还集成了员工信息通知功能可实时同步出差安排与审批状态体现了消息推送在实际业务中的落地方式。目前已有119人学习下载可作为课程设计、技术面试或企业内训的参考源码学习时能重点掌握表单数据流、审批状态机与前后端交互等关键开发技巧。1. 企业级差旅管理系统Java与前端技术落地时真正的难点在哪里企业级差旅管理系统用 Java 和前端技术落地真正的难点不是把申请单和报销单做成增删改查而是把“事前申请、事中预订、事后报销”三段流程里的预算和状态穿成一条线。一次出差会经过申请、审批、下单、供应商回票、财务对账五个节点每个节点都有独立状态机还要在审批通过那一刻把成本中心预算锁住否则月底对账就会炸出一堆超预算单。这篇按我自己做这类系统的顺序来讲先划业务域和前端技术栈选型再给审批流和并发预算控制的方案然后落到前后端联调的接口与页面数据流最后用一个压测脚本把最容易翻车的地方提前验掉。适合有 Java 基础、想从单体 CRUD 往企业级业务流程系统走的人也适合在外采 SaaS 和自研之间摇摆的团队。2. Java业务域拆解与前端技术选型差旅管理系统的ER图先于源码定下来2.1 五张核心表的ER图申请单、订单、报销单分家差旅管理系统的业务边界可以用五张表概括。表名主键关键字段职责biz_travel_applyidapply_no、applicant_id、cost_center_id、departure_date、return_date、estimated_amount、status、budget_occupied差旅申请主单biz_travel_orderidorder_no、apply_id、order_type、supplier_type、supplier_order_no、amount、status差旅预订订单biz_travel_expenseidexpense_no、apply_id、order_ids、total_amount、invoice_no、status差旅报销单biz_travel_approval_taskidapply_id、node_id、approver_id、approve_action、comment、create_time审批任务明细biz_budget_accountidcost_center_id、total_amount、occupied_amount、paid_amount、available_amount、version成本中心预算账户申请单是“意向”订单是“实际花费”报销单是“财务凭证”。申请单和订单是1:N订单和报销单在差旅场景里通常N:1因为一次出差可能分多次预订。曾经我见过同事把订单金额直接回写到申请单的 estimated_amount月末财务一拉预算全是偏差这就是ER图没理清导致的。这五张表的ER图先于源码画清楚业务方和开发对“机票订单挂在申请单下还是报销单下”就不存在分歧。实际上申请单到订单再到报销单的状态映射也是 Java 面试题里常考的业务建模题考的就是多状态实体的边界划分。2.2 申请单的关键表结构与字段约束申请单最容易被忽略的是预算字段的三个属性预估、预占、实付。下面建一张最小可用表。CREATE TABLE biz_travel_apply ( id BIGINT AUTO_INCREMENT PRIMARY KEY, apply_no VARCHAR(32) NOT NULL COMMENT 申请单号格式 SQyyyyMMdd流水, applicant_id BIGINT NOT NULL COMMENT 申请人ID, cost_center_id BIGINT NOT NULL COMMENT 成本中心ID, departure_date DATE NOT NULL COMMENT 出发日期, return_date DATE NOT NULL COMMENT 返程日期, estimated_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 预估金额, budget_occupied DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 审批通过后预占金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1审批中 2通过 3驳回 4撤销, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_applicant_status (applicant_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT差旅申请单;几个字段我要单独解释。budget_occupied在审批通过之前始终为0它记录的是通过那一刻锁住的预算不等同于预估金额如果申请被部分批准这个字段要按批准金额重新赋值。departure_date和return_date都用 DATE 而不是 DATETIME减少时区换算的脏数据。金额一律 DECIMAL(12,2)不要用 float/double账务计算里的精度问题往往从字段类型开始。status用 TINYINT 加常量类不要在业务代码里写魔法数字1、2、3否则半年后没人敢迁移状态。2.3 后端模块边界与前端技术栈选型后端我的习惯是多模块单体起步而不是一上来拆微服务。travel-system/ travel-common/ # 统一响应、异常、常量 travel-workflow/ # 审批流流程模板 任务解析 travel-order/ # 订单、供应商适配 travel-finance/ # 预算、报销、对账 travel-app/ # Spring Boot 启动模块企业级差旅系统初期的流量规模一个 Spring Boot 应用完全扛得住拆分到模块层已经把领域边界切开了。将来订单量上来把 travel-order 单独部署只改启动模块的包扫描路径。数据库初期也不要分库但要把不同模块的表放到不同 schema或者至少用表前缀区分避免后续分表时改 SQL 改到怀疑人生。前端技术栈选型上看中后台差旅管理页面以表格、表单、审批抽屉为主Vue3 Element Plus 是上手成本最低的组合。我的选型表如下。依赖用途Vue 3 Vite应用框架与构建Element Plus表格、表单、日期范围、弹窗组件Pinia用户信息与权限码缓存Vue Router路由守卫与动态菜单AxiosREST API 请求与拦截器Day.js日期范围校验与格式统一React Ant Design 不是不能用但差旅系统的表单验证和表格列显隐逻辑Element Plus 的插槽更直接。如果企业已经有一个统一的门户再考虑 qiankun 微前端只做差旅这一个系统不要为了技术热度引入微前端。2.4 先定状态流转再写业务方法差旅申请单的状态流转比订单要简单草稿、审批中、通过、驳回、撤销外加审批通过后可能出现的“部分退票”导致申请单回到“变更中”。不要把状态流转写成 if/else 嵌套我用一个状态机表驱动。private static final MapInteger, SetInteger APPLY_STATE_MAP Map.of( 0, Set.of(1), 1, Set.of(2, 3, 4), 2, Set.of(4), 3, Set.of(), 4, Set.of() );APPLY_STATE_MAP的 key 是当前状态value 是允许迁移到的状态集合。提交申请时校验stateMap.get(current).contains(target)不合法直接抛业务异常。这套写法不需要引入额外的状态机框架而且状态解码逻辑集中在一个常量类里前端下拉框的候选值也能直接从这个 Map 生成。真正复杂的流程控制留给第三章的审批工作流。3. 审批工作流与预算并发控制Java后端最容易写差的三个位置3.1 Flowable与自研状态机的取舍企业级差旅的审批链通常有三到五级部门主管、财务复核、超过金额后财务总监加签。有人一听到工作流就上 Flowable结果一个审批单建了三四张流程实例表排错成本比开发成本还高也有人坚持所有审批都写在 service 里遇到加签和会签就推倒重来。我的选择标准是这样。维度Flowable自研状态机动态加签/会签支持完善要自己做任务表流程版本升级通过 processDefinitionKey 管理通过节点配置表管理学习与排错成本较高涉及几十张引擎表低一条 SQL 能查清适用规模审批模式经常变的大型组织审批链相对固定的部门级系统差旅审批属于“模式固定但偶尔加签”的场景我一般用轻量状态机加审批任务表审批链存成 JSON 模板。只有当企业每次审批都要求可视化编排流程、甚至让业务部门自己画流程图时才值得把 Flowable 拉进来。选 Flowable 也有版本坑5.x、6.x、7.x 对 Spring Boot 2/3 的支持差异明显依赖版本没对齐会直接启动失败。3.2 预算占用必须把余额判断也写进UPDATE预算预占是这个系统最不能出错的地方。审批通过时系统要把预估金额从成本中心预算中锁住代码第一版很可能写成先 SELECT 再 UPDATE。// 错误示范先查后改两个请求可能同时读到可用余额 BigDecimal available budgetMapper.getAvailable(costCenterId); if (available.compareTo(amount) 0) { budgetMapper.occupy(costCenterId, amount); }我先查再改的写法在并发下一定会超占。两个审批请求同时读到余额1000各自扣了800账户就变成-600。正确做法是把余额判断写进 UPDATE 条件。UPDATE biz_budget_account SET occupied_amount occupied_amount #{amount}, available_amount available_amount - #{amount}, version version 1 WHERE cost_center_id #{costCenterId} AND available_amount #{amount}执行后检查影响行数等于1才表示占用成功等于0就抛“预算不足”。这段 SQL 利用数据库行锁的原子性把并发控制从代码挪到了数据库。这个问题在 Java 面试八股文里常以“乐观锁失效”出现实际差旅预算里就是一条 UPDATE 解决不用分布式锁。3.3 Redis预占的increment()报错排查预算落库是最终依据但热数据也要扛住前端快速点击我会把成本中心预算的余量在 Redis 里预存一份做快速预检。String key travel:budget:occupy: costCenterId; Boolean first redisTemplate.opsForValue() .setIfAbsent(key, String.valueOf(totalLimit), 24, TimeUnit.HOURS); Long remain redisTemplate.opsForValue().increment(key, -applyAmount); if (remain null || remain 0L) { redisTemplate.opsForValue().increment(key, applyAmount); throw new BizException(预算不足); }setIfAbsent只负责初始化预算池之后每次预占都用increment做原子减。这里有个非常经典的运行时异常java.lang.IllegalArgumentException: value not an integer or out of range它通常不是代码逻辑问题而是同一个 key 里被写进了 JSON 序列化对象或带引号的字符串。Redis 的 increment 要求 value 必须是十进制数字字符串任何对象序列化结果都会触发这个报错。排查方法是直接GET travel:budget:occupy:1001看返回值是不是8000而不是{total:8000}。预算 key 要单独规范不要和对象缓存复用同一个 key 前缀。3.4 审批人不能写死要用表达式解析差旅审批最麻烦的地方是审批人经常变。有人离职、组织调整、跨部门出差写死在流程定义里的审批人三个月后全是失效的。我的做法是审批节点只存一个表达式。private Long resolveApprover(String expression, TravelApplyBO apply) { String[] parts expression.split(:); if (director.equals(parts[0])) { return orgService.getDepartmentManager(apply.getApplicantId()); } if (budget.equals(parts[0])) { return financeConfig.getBudgetApprover(apply.getCostCenterId()); } if (user.equals(parts[0])) { return Long.valueOf(parts[1]); } throw new BizException(无法解析审批人表达式: expression); }表达式格式是director:dept:1、budget:CC001:0、user:10086:0这类三段式第一部分表示审批人来源第二部分是动态查询入参第三位预留。节点表只存表达式字符串审批人是谁由组织架构服务在任务创建时实时解析。这样人事变动不会触发代码发布流程模板的维护成本也低得多。3.5 审批通知走异步不要阻塞主链路审批动作的响应速度直接影响用户体验。提交申请后系统要通知主管、财务和申请人如果每路通知都同步发送短信通道一抖动审批接口就跟着超时。我会用 RabbitMQ 把通知拆出去。rabbitTemplate.convertAndSend( travel.exchange, approval.notice, noticeDTO );sendAndReceive 那种等待所有消费者处理完的调用要避免审批主链路只负责写业务状态通知是否送达由消费者负责重试。这也是很多 Java 并发问题“线程等待都完成”的翻版主线程等所有下游返回结果下游最慢的接口决定了整个链路耗时。差旅申请提交接口目标值在 200ms 以内通知是允许 2 秒后才到达的。MQ 消费端要做幂等用消息里的 applyId nodeId 做去重键防止主管收到两条一模一样的审批提醒。4. 从REST API到Vue3页面差旅申请单的前后端联调数据流4.1 后端申请单接口与参数校验差旅申请接口设计我坚持一个原则入参是 DTO返回值是 VO业务对象只在 service 层内部流转。PostMapping(/api/travel/apply) public ResultLong submitApply(RequestBody Valid ApplyCreateRequest req) { if (req.getReturnDate().isBefore(req.getDepartureDate())) { throw new BizException(返程日期不能早于出发日期); } TravelApplyBO bo applyAssembler.toBO(req); return Result.ok(applyService.submit(bo)); }Valid触发 DTO 字段上的 JSR 303 校验日期先后这类跨字段逻辑不在注解能力范围内所以单独写在方法开头。DTO 里金额校验用DecimalMin(value 0.01, message 预估金额必须大于0)事由用NotBlank(message 出差事由不能为空)部门写NotNull。这里要注意MyBatis-Plus 提供的实体类注解只解决数据库映射不要拿TableField那套东西做接口入参校验进参和落库字段经常不一致混在一起会让接口语义变模糊。4.2 供应商下单的适配层设计差旅预订环节最怕供应商绑死。机票接了一家服务商第二年合同到期要换如果下单逻辑散落在 service 里替换成本极高。我会定义一个供应商接口每个供应商一个实现类。public interface TravelSupplier { String supplierType(); SearchResult search(SearchCommand cmd); OrderResult createOrder(OrderPlaceCmd cmd); }Component public class CtripSupplier implements TravelSupplier { Override public String supplierType() { return CTRIP; } }Component public class SupplierRouter { private final MapString, TravelSupplier supplierMap; public SupplierRouter(ListTravelSupplier suppliers) { supplierMap suppliers.stream() .collect(Collectors.toMap(TravelSupplier::supplierType, s - s)); } }SupplierRouter的构造器注入会收集 Spring 容器里所有TravelSupplier实现按供应商类型拼成 Map。下单时supplierMap.get(order.getSupplierType()).createOrder(cmd)新增一家供应商只是新增一个实现类。和供应商联调时Mock 供应商可以直接用 JDK Proxy 动态包装一个假实现返回固定航班和价格不必起真实服务。4.3 前端审批列表与详情页的数据流审批列表页是差旅系统前端最核心的页面。列表加载、审批通过、审批驳回三个动作对应三个接口页面不做本地状态机缓存。script setup langts import { onMounted, reactive, ref } from vue import { getApprovalTasks, completeTask } from /api/workflow import { ElMessage } from element-plus const query reactive({ page: 1, size: 20, status: }) const rows refApprovalTaskItem[]([]) const total ref(0) async function load() { const res await getApprovalTasks(query) rows.value res.data.records total.value res.data.total } async function onApprove(row: ApprovalTaskItem, pass: boolean) { await completeTask({ taskId: row.taskId, pass, comment: row.comment }) ElMessage.success(pass ? 已通过 : 已驳回) load() } onMounted(load) /scriptonApprove里先调接口再刷新列表是因为审批通过后这个任务会从待办列表消失前端如果自己把行摘掉很容易和最新服务端状态不一致。列表页每天被审批人打开几十次这种页面追求的是“数据准”而不是“动画炫”。审批详情页要同时展示申请单信息、预算占用情况和历史审批记录后端建议用一个聚合接口一次返回不要前端发三个请求再拼数据差旅申请详情页并发场景不高但减少请求数能少处理三倍的空态和 loading。4.4 路由守卫的权限要在后端再拦一道前端权限只做菜单和按钮显隐真正的鉴权一定在后端接口上。router.beforeEach(async (to) { const userStore useUserStore() if (!userStore.token) return /login if (!userStore.permissions.length) { await userStore.fetchPermissions() } return true })按钮级权限用userStore.hasPerm(travel:apply:approve)控制审批按钮是否渲染。但如果接口层不加控制前端把按钮用样式挡住并不影响直接调用接口所以 Controller 方法上必须配PreAuthorize(hasAuthority(travel:apply:approve))双层都做。企业级系统的权限点粒度要落到“申请”“审批”“报销”“对账”四个模块的操作级不要只分管理员和普通员工两种角色。4.5 审批状态的联动更新审批通过后申请单状态要改、预算要占用、待办任务要归档这三步必须在同一个事务里完成。事务方法里我按“先写业务表再发MQ”的顺序排MQ 发送放在事务提交后避免消息先到消费者、消费者回查业务数据时事务还没提交。Transactional(rollbackFor Exception.class) public void approve(Long taskId, boolean pass, String comment) { approvalTaskMapper.updateAction(taskId, pass, comment); if (pass) { applyService.updateStatus(applyId, 2); budgetService.occupy(costCenterId, approveAmount, applyId); } transactionSynchronization.afterCommit(() - notifyCenter.send(taskId)); }afterCommit注册的代码块会在事务真正提交后执行此时再发通知消费者拿到的数据一定是最新的。这是我在做差旅系统时被 MQ 和数据库不一致坑过一次之后才总结的写法事务未提交就发消息消费者往往读到旧状态导致“主管点了通过员工看到还是审批中”的诡异现象。5. 落地验证与排错脚本把预算超支和单据不一致在发版前揪出来5.1 幂等键挡住重复提交审批通过按钮被快速点两次或者前端的 request 重试机制把同一个审批请求发了两遍就会造成预算占用两次。我习惯在提交审批动作时生成一个幂等键。String idempotentKey travel:approve: taskId : pass; Boolean first redisTemplate.opsForValue() .setIfAbsent(idempotentKey, 1, 10, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(first)) { throw new BizException(请勿重复提交审批); }setIfAbsent返回 true 表示这个审批动作第一次执行返回 false 说明已经处理过直接拒绝。Redis 的过期时间设 10 分钟保证在前端 loading 超时重试的窗口期里能兜住重复请求。5.2 用JMeter压测预算并发占用发版前要验证预算占用是否真的安全写一个压测场景50 个线程同时审批通过 100 张申请单全部落到同一个成本中心。压测命令如下。jmeter -n -t travel-approve.jmx -l result.jtl -e -o report-n表示命令行非 GUI 模式-t指定测试计划文件-l输出结果文件-e -o生成 HTML 报告。压测结束后直接对着预算账户跑一条对账 SQL。SELECT cost_center_id, occupied_amount, paid_amount, available_amount FROM biz_budget_account WHERE cost_center_id CC001;再把所有审批通过但未撤销的申请单的budget_occupied求和和occupied_amount对比。两个值不一致说明 UPDATE 条件里的余额判断或事务边界出了问题。5.3 一张SQL找出状态不一致的孤儿订单差旅系统最常见的脏数据是“申请单已撤销订单还活着”。撤销申请时如果只改了申请单状态没有联动处理已创建的订单就会出现审批人看到申请人撤销了供应商那边机票还是出票状态。SELECT o.id, o.order_no, o.apply_id, a.status AS apply_status FROM biz_travel_order o LEFT JOIN biz_travel_apply a ON o.apply_id a.id WHERE a.status 4 AND o.status NOT IN (REFUNDED, CANCELED, CLOSED);把这个查询放在发版后的巡检任务里每天跑一次。结果不为空时检查撤销接口是否遗漏了订单联动如果确实有异常单对照订单状态手动流转到已退票。这类脚本比单元测试更能暴露真实环境里接口调用的遗漏。最后再把审批压测的采样器加一个断言断言审批接口返回结果里的occupiedAmount总和不超过预算总额用这个断言替代人眼对账后续每次代码变更都能在 CI 里自动验证预算并发逻辑。本文还有配套的精品资源点击获取