尧图精选

基于Java的TMS物流运输系统后端源码:运单状态机与计费引擎设计

🕒 发布时间:2026/9/28 1:08:25 📁 来源:尧图网络
简介这是一套面向Java后端开发者与物流信息化学习者的TMS物流运输系统后端源码适合需要理解企业级运输管理系统架构、或希望以此为基础进行二次开发与课程设计的技术人员。项目围绕订单管理、运输路线规划、货物跟踪等核心业务展开涵盖委托、调度、结算、车队、网关等模块目录划分清晰便于按业务域阅读与拆解。压缩包共193个文件约536KB其中144个Java源文件承载主要业务逻辑28个XML与10个YAML文件负责框架与参数配置另有JAR包、Properties配置、Markdown说明及mvnw构建脚本覆盖开发、打包与部署环节。目前已有635人学习下载可作为理解TMS后端分层设计、接口组织与配置管理的实战参考帮助读者快速把握物流系统从订单接入到运输跟踪的完整链路并借鉴其模块化组织方式用于自身项目。1. 从一张运单说起Java TMS 物流运输系统后端到底在解决什么问题一张运单从货主下单到签收回单中间要经过调度派车、司机接单、在途上报、到达卸货、回单上传、对账结算至少六个状态节点。很多中小物流公司还在用 Excel 加微信群管这些事调度员一天要打几十个电话确认车到哪了财务月底对账靠翻聊天记录。TMSTransportation Management System物流运输系统要干的事就是把这套流程搬到线上让状态流转有记录、运费计算有依据、异常情况能追溯。这个标题里的关键词是「基于 Java」「后端」「源码」。它指向的是一套用 Java 技术栈实现的 TMS 服务端工程通常采用 Spring Boot MyBatis 的分层结构对外提供 REST 接口给调度端、司机端、货主端调用。适合谁看一是要做课程设计或毕业设计的学生需要一套结构完整、能跑起来的后端参考二是中小物流公司内部想自建系统的开发者需要知道运单状态机、计费规则、车辆调度这些模块怎么落地三是想从 CRUD 项目进阶到业务系统的 Java 后端TMS 的领域模型比商城、博客复杂得多是个不错的练手方向。我做过两版 TMS 后端第一版把运单状态硬编码在 Service 里后来加「部分签收」「拒收退回」两个状态时改了十几个文件血泪经验告诉我运输系统的核心不是增删改查而是状态机和计费引擎的设计。下面按「领域建模 → 工程搭建 → 核心模块 → 避坑 → 进阶」的顺序把一套可复现的 Java TMS 后端方案讲清楚。2. 领域建模与工程骨架先把运单状态机和计费模型定下来2.1 运单状态机为什么不能用一个 status 字段打天下新手最容易犯的错是在tms_order表里放一个status字段值从 0 到 9 表示不同状态然后在 Service 里写一堆if (status 3)。这种写法在状态少于 5 个、流转路径单一的时候能跑但 TMS 的运单状态是典型的有限状态机待调度 → 已派车 → 已装货 → 运输中 → 已到达 → 已签收中间还可能插入「取消」「异常」「拒收」等分支而且每个状态允许的操作不同比如「运输中」不能改收货地址。常见做法是用枚举加状态流转表来约束。先定义状态枚举public enum OrderStatus { PENDING_DISPATCH(10, 待调度), DISPATCHED(20, 已派车), LOADED(30, 已装货), IN_TRANSIT(40, 运输中), ARRIVED(50, 已到达), SIGNED(60, 已签收), CANCELLED(90, 已取消), EXCEPTION(99, 异常); private final int code; private final String desc; // 构造、getter 省略 }然后用一张流转规则表约束「从哪个状态能到哪个状态」public class OrderStateMachine { private static final MapOrderStatus, SetOrderStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)); TRANSITIONS.put(DISPATCHED, Set.of(LOADED, CANCELLED, EXCEPTION)); TRANSITIONS.put(LOADED, Set.of(IN_TRANSIT, EXCEPTION)); TRANSITIONS.put(IN_TRANSIT, Set.of(ARRIVED, EXCEPTION)); TRANSITIONS.put(ARRIVED, Set.of(SIGNED, EXCEPTION)); // 终态不再流转 } public static boolean canTransfer(OrderStatus from, OrderStatus to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }逻辑说明TRANSITIONS用静态块初始化key 是当前状态value 是允许到达的下一状态集合。每次改状态前调canTransfer校验不合法就抛业务异常。参数上状态码用 10、20 这种间隔值而不是 1、2、3是为了以后在中间插入新状态时不用改已有数据。这套写法比在 Service 里堆 if-else 清晰得多加状态只需要改枚举和流转表两处。2.2 计费模型按重计费、按方计费、按趟计费怎么抽象运费计算是 TMS 里第二个容易翻车的地方。不同客户签的合同不一样有的按重量元/吨有的按体积元/方有的按趟包干还有的起步价加超出部分。如果给每种规则写一个 Service 方法客户一多就爆炸。我一般会抽象一个计费策略接口public interface FreightCalculator { BigDecimal calculate(FreightContext context); } public class FreightContext { private BigDecimal weight; // 重量吨 private BigDecimal volume; // 体积方 private BigDecimal distance; // 距离公里 private BigDecimal startPrice; // 起步价 private BigDecimal unitPrice; // 单价 // getter/setter 省略 }按重计费的实现public class WeightBasedCalculator implements FreightCalculator { Override public BigDecimal calculate(FreightContext ctx) { BigDecimal base ctx.getStartPrice() null ? BigDecimal.ZERO : ctx.getStartPrice(); BigDecimal freight ctx.getWeight().multiply(ctx.getUnitPrice()); return base.add(freight).setScale(2, RoundingMode.HALF_UP); } }逻辑说明FreightContext把所有可能用到的计费因子都装进去策略实现按需取用。setScale(2, HALF_UP)保证金额保留两位小数且四舍五入避免double精度问题——金额一律用BigDecimal这是铁律。参数上startPrice允许为空为空时按 0 处理这样按趟包干的场景只需要传startPrice即可。策略选择用工厂模式根据客户合同里的billingType字段决定新增计费方式只需要加一个实现类不动已有代码。2.3 工程骨架Spring Boot 分层与依赖选型一套能跑的 TMS 后端我一般用 Spring Boot 2.7 或 3.x看 JDK 版本JDK 8 用 2.7JDK 17 用 3.x核心依赖如下依赖用途选型理由spring-boot-starter-webREST 接口标配不用多说mybatis-plusORM比原生 MyBatis 少写 XML分页插件开箱即用mysql-connector-java数据库驱动MySQL 8 用 8.x 驱动spring-boot-starter-validation参数校验NotNull、Valid省掉手写校验hutool-all工具类日期、加密、Excel 导出都能用spring-boot-starter-data-redis缓存运单号生成、司机位置缓存包结构按领域划分不要按技术分层com.example.tms ├── order // 运单域 │ ├── controller │ ├── service │ ├── mapper │ ├── entity │ └── dto ├── dispatch // 调度域 ├── vehicle // 车辆域 ├── driver // 司机域 ├── billing // 计费域 └── common // 公共异常、拦截器、工具按领域分包的好处是改运单相关逻辑时只在一个目录里动不会像按 controller/service/mapper 分层那样满工程找文件。这个结构在若依框架前后端分离版本里也是类似思路可以直接参考它的目录组织。3. 核心模块落地运单、调度、计费、回单四块的实现细节3.1 运单模块运单号生成与幂等创建运单号是 TMS 的门面格式一般是「前缀 日期 流水号」比如TMS202405200001。用数据库自增 ID 拼日期最简单但并发下容易重复。我一般用 Redis 的INCR保证原子性Service public class OrderNoGenerator { Autowired private StringRedisTemplate redisTemplate; public String generate() { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String key tms:order:no: date; Long seq redisTemplate.opsForValue().increment(key); // 首次设置过期时间为 2 天避免 key 堆积 if (seq ! null seq 1) { redisTemplate.expire(key, Duration.ofDays(2)); } return TMS date String.format(%04d, seq); } }逻辑说明increment是原子操作多实例部署也不会重复。seq 1时设置过期时间保证每天一个 key两天后自动清理。参数上%04d表示流水号补零到 4 位一天超过 9999 单的物流公司要改成%06d。如果 Redis 挂了降级方案是用数据库的order_no唯一索引兜底插入冲突时重试。创建运单还要考虑幂等同一个客户短时间内重复提交不能生成两张单。常见做法是客户端传一个requestId服务端用 RedissetIfAbsent做去重public Order createOrder(CreateOrderDTO dto) { String idempotentKey tms:order:idem: dto.getRequestId(); Boolean success redisTemplate.opsForValue() .setIfAbsent(idempotentKey, 1, Duration.ofMinutes(10)); if (Boolean.FALSE.equals(success)) { throw new BizException(请勿重复提交); } // 后续创建逻辑 }参数说明requestId由前端生成一般是 UUID10 分钟过期意味着同一请求 10 分钟内只能创建一次。这个 key 要在创建成功后保留不能提前删否则幂等失效。3.2 调度模块车辆司机匹配与派车单生成调度是 TMS 里业务最复杂的模块。核心逻辑是根据运单的起点、终点、货物重量体积从可用车辆里筛选匹配的车然后生成派车单。筛选条件一般包括车辆状态为空闲、载重够、车型匹配厢式/平板/冷藏、司机有对应驾照。public ListVehicle matchVehicles(DispatchQuery query) { LambdaQueryWrapperVehicle wrapper new LambdaQueryWrapper(); wrapper.eq(Vehicle::getStatus, VehicleStatus.IDLE.getCode()) .ge(Vehicle::getLoadCapacity, query.getWeight()) .eq(query.getVehicleType() ! null, Vehicle::getType, query.getVehicleType()) .orderByAsc(Vehicle::getLastDispatchTime); // 优先派最近没出车的 return vehicleMapper.selectList(wrapper); }逻辑说明ge是大于等于保证载重够eq带条件参数vehicleType为空时不参与筛选orderByAsc按上次派车时间升序实现简单的公平调度。参数上loadCapacity单位是吨要和运单重量单位一致单位不统一是常见 bug 来源。派车单生成后要把车辆状态改成「已派车」这一步必须和派车单插入在同一个事务里Transactional(rollbackFor Exception.class) public DispatchOrder dispatch(Long orderId, Long vehicleId, Long driverId) { // 1. 校验运单状态 Order order orderMapper.selectById(orderId); if (!OrderStateMachine.canTransfer(order.getStatus(), OrderStatus.DISPATCHED)) { throw new BizException(当前状态不可派车); } // 2. 插入派车单 DispatchOrder dispatch new DispatchOrder(); dispatch.setOrderId(orderId); dispatch.setVehicleId(vehicleId); dispatch.setDriverId(driverId); dispatchMapper.insert(dispatch); // 3. 更新运单状态 order.setStatus(OrderStatus.DISPATCHED); orderMapper.updateById(order); // 4. 更新车辆状态 vehicleMapper.updateStatus(vehicleId, VehicleStatus.DISPATCHED.getCode()); return dispatch; }Transactional(rollbackFor Exception.class)是关键默认只回滚RuntimeException加上rollbackFor保证任何异常都回滚。四步操作要么全成功要么全失败否则会出现「派车单有了但车辆还是空闲」的脏数据。3.3 计费模块合同价与临时价的优先级实际业务里运费有两种来源一是客户签的合同价二是临时议价。优先级一般是合同价优先没有合同才用临时价。实现上先查客户合同public BigDecimal calcFreight(Long orderId) { Order order orderMapper.selectById(orderId); // 1. 查客户合同 Contract contract contractMapper.selectByCustomerId(order.getCustomerId()); FreightContext ctx buildContext(order); if (contract ! null) { ctx.setStartPrice(contract.getStartPrice()); ctx.setUnitPrice(contract.getUnitPrice()); FreightCalculator calculator FreightCalculatorFactory.get(contract.getBillingType()); return calculator.calculate(ctx); } // 2. 无合同用临时价 if (order.getTempPrice() null) { throw new BizException(无合同且无临时价无法计费); } return order.getTempPrice(); }逻辑说明buildContext从运单里取重量、体积、距离等因子FreightCalculatorFactory.get根据合同的billingType返回对应策略。参数上合同表要有customerId和billingType字段billingType用枚举字符串WEIGHT/VOLUME/TRIP而不是数字可读性更好。临时价直接存在运单表上调度员录入。计费结果要落库到order_freight表不能每次查询都重算否则合同改了历史运单金额会变这是财务大忌。3.4 回单模块文件上传与状态回写回单是签收凭证司机拍照片上传。后端要处理文件存储和运单状态回写。文件存储常见做法是存本地磁盘或对象存储数据库只存路径PostMapping(/receipt/upload) public ResultString uploadReceipt(RequestParam(file) MultipartFile file, RequestParam(orderId) Long orderId) { // 1. 校验文件类型 String originalName file.getOriginalFilename(); String suffix originalName.substring(originalName.lastIndexOf(.) 1).toLowerCase(); if (!Set.of(jpg, jpeg, png, pdf).contains(suffix)) { throw new BizException(不支持的文件类型); } // 2. 按日期分目录存储 String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); String fileName UUID.randomUUID() . suffix; Path target Paths.get(/data/tms/receipt, datePath, fileName); Files.createDirectories(target.getParent()); file.transferTo(target.toFile()); // 3. 回写运单 String url /receipt/ datePath / fileName; orderMapper.updateReceipt(orderId, url); return Result.ok(url); }逻辑说明后缀白名单校验防止上传脚本文件这是安全底线按日期分目录避免单目录文件过多文件名用 UUID 防止覆盖和路径猜测。参数上/data/tms/receipt是存储根路径生产环境要挂独立数据盘不要和系统盘混用。回单上传后运单状态是否自动改成「已签收」要看业务有的公司要求调度确认后才签收那就只存文件不改状态。注意文件上传接口一定要限制大小Spring Boot 默认单文件 1MB、单请求 10MB在application.yml里按需调整别让司机传个 20MB 的原图把服务打挂。4. 避坑与排查TMS 后端上线后最容易翻车的五个地方4.1 运单状态被并发改乱现象两个调度员同时操作同一张运单一个派车一个取消结果运单状态变成「已取消」但派车单还在车辆被占用。原因状态校验和更新之间没有加锁两个请求都通过了canTransfer校验然后各自更新。解决用乐观锁。在tms_order表加version字段更新时带上版本号UPDATE tms_order SET status #{status}, version version 1 WHERE id #{id} AND version #{version}影响行数为 0 说明被别人改过抛异常让前端重试。MyBatis-Plus 的Version注解能自动处理不用手写 SQL。4.2 计费金额出现小数精度错误现象运费算出来是 1234.5600000000002 这种值财务对不上账。原因用了double或float做金额运算浮点数本身有精度损失。解决所有金额字段用BigDecimal数据库用DECIMAL(12,2)Java 里运算用BigDecimal的方法最后setScale(2, RoundingMode.HALF_UP)。别用new BigDecimal(0.1)要用new BigDecimal(0.1)前者会带入 double 的误差。4.3 运单号在 Redis 重启后重复现象Redis 没设持久化重启后INCR从 1 开始生成的运单号和当天已有的重复插入报唯一索引冲突。原因运单号生成强依赖 Redis 的计数器Redis 数据丢了计数器就归零。解决两个办法。一是 Redis 开 AOF 持久化二是数据库order_no加唯一索引插入冲突时捕获异常重新生成。我一般两个都做唯一索引是最后一道防线。另外可以在生成时先查一下数据库当天最大流水号取max(redis值, db值1)多一次查询换安全。4.4 派车后车辆状态没同步现象派车单生成了但车辆列表里这辆车还是「空闲」被重复派出去。原因派车逻辑里更新车辆状态的代码在事务外或者更新时没加车辆状态的条件判断。解决更新车辆状态时带上原状态条件UPDATE tms_vehicle SET status DISPATCHED WHERE id #{id} AND status IDLE影响行数为 0 说明车已经被别人派了回滚整个派车事务。这个「条件更新」模式在库存扣减、状态流转场景都通用是防并发的基础手段。4.5 回单文件把磁盘写满现象服务跑几个月后磁盘告警一看回单目录几百 GB。原因司机上传的原图没压缩一张 5MB一天几千张。解决上传时压缩。用Thumbnails库把图片压到长边 1920、质量 0.8一般能压到 300KB 以内。PDF 不压缩但限制页数。另外加定时任务清理超过保留期的回单比如 2 年归档到冷存储。存储路径按日期分目录的好处这时候也体现出来了清理时按目录删不用扫全表。5. 进阶用状态机引擎和领域事件把 TMS 后端做扎实前面用枚举加流转表实现的状态机在状态少于 10 个、流转规则固定时够用。但如果要支持不同客户自定义流程比如大客户要求「到达后先预约再卸货」硬编码的流转表就不够了。这时候可以引入 Spring StateMachine 或轻量的squirrel-foundation把状态、事件、动作、守卫条件配置化。我一般用 Spring StateMachine因为它和 Spring 生态集成好配置类里定义状态和转移Configuration EnableStateMachineFactory public class OrderStateMachineConfig extends StateMachineConfigurerAdapterOrderStatus, OrderEvent { Override public void configure(StateMachineTransitionConfigurerOrderStatus, OrderEvent transitions) throws Exception { transitions .withExternal().source(OrderStatus.PENDING_DISPATCH).target(OrderStatus.DISPATCHED) .event(OrderEvent.DISPATCH).action(dispatchAction()) .and() .withExternal().source(OrderStatus.DISPATCHED).target(OrderStatus.LOADED) .event(OrderEvent.LOAD); } }dispatchAction()里做派车单生成和车辆状态更新状态流转和业务动作绑在一起不会漏。代价是学习成本高小项目没必要上。另一个进阶方向是领域事件。运单签收后要触发一系列动作通知货主、生成对账单、更新司机绩效。如果全写在签收方法里方法会越来越长。用 Spring 的ApplicationEventPublisher发一个OrderSignedEvent各个监听器各管各的// 发布 applicationEventPublisher.publishEvent(new OrderSignedEvent(orderId)); // 监听 EventListener public void onOrderSigned(OrderSignedEvent event) { billingService.generateStatement(event.getOrderId()); }这样签收逻辑只负责改状态和发事件对账、通知、绩效都是独立监听器加需求不动主流程。注意事件监听默认同步执行耗时操作要加Async并配线程池否则签收接口会变慢。验证一套 TMS 后端是否做扎实我一般看三个指标一是并发派车同一辆车只有一单成功二是同一运单重复提交创建只生成一张单三是合同价改了之后历史运单金额不变。这三个过了基本盘就稳了。最后说个习惯我每加一个状态流转都会在测试类里写一条「非法流转必须抛异常」的用例比如从「已签收」直接改「运输中」。这种用例写起来快但能挡住后面无数次手滑。TMS 这种系统数据错了比功能少了严重得多宁可多写几行校验也别给财务留后悔药。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →