
简介TMS运输管理系统是一套基于Java的运输管理后台解决方案主要面向物流行业开发者、Java技术学习者及企业信息化人员用于掌握运输订单、路线、车辆调度、成本与报表等核心业务模块的设计与实现。资源为zip压缩包大小约74.39MB文件清单及类型明细暂未公开但包内目录结构清晰可按模块顺序阅读与复用。目前已有2368人浏览学习。系统涵盖订单管理、路线规划与优化、资源调度、成本控制、货物追踪、报表分析及ERP/WMS接口集成等能力代码与配置不仅可作为二次开发的基础也适合作为JavaWeb项目实战参考帮助读者快速理解运输调度逻辑、成本分析思路以及系统集成方式从而缩短项目开发周期。对于想了解TMS业务流程或将运输管理模块落地到自有系统的开发者具有较高的借鉴价值。1. 一套能直接上手的 TMS 运输管理系统从 Excel 排车到派车闭环前阵子某物流公司的调度员给我看了他们的日常一辆一辆打电话确认车在不在、货装没装完回来再拿 Excel 登记。我拆完这套 TMS 运输管理系统之后的想法是——这大概就是中小型物流团队最容易落地的 Java 后台管理系统。它用 Spring Boot MyBatis 搭了一个完整的单体应用业务覆盖客户管理、订单录入、调度派车、运单跟踪、签收和月度报表前端用的 AdminLTE 模板不搞前后端分离部署门槛低。适合谁刚把业务从纸面搬到线上的物流公司或者想快速套壳做管理后台的 Java 程序员。2. 技术架构与项目结构Spring Boot MyBatis 的经典单体工程拿到源码先别急着跑把这个项目的骨架看清后面改起来心里才有底。这套项目走的是典型的 Java Web 单体架构分层清晰依赖也不多属于「一个 Maven 命令就能拉起来」的类型。2.1 技术选型为什么不是微服务而是 Spring Boot 单体项目没有引入微服务那一套核心依赖集中在 Spring Boot、MyBatis、Thymeleaf 上。我拆项目时核对了一下技术栈大致是下面这个组合组件选择说明JDK8稳定绝大多数生产环境还在用框架Spring Boot 2.7.x内置 Tomcat打 jar 就能跑ORMMyBatis 3 PageHelperXML 里写 SQL报表统计方便模板引擎Thymeleaf服务端渲染后台页面直接由 Controller 返回视图前端 UIAdminLTE 3 Bootstrap免费后台模板自带侧边栏和表格样式数据库MySQL 5.7 / 8.0单机部署避免主从带来的配置成本鉴权登录拦截器 Session手写 HandlerInterceptor简单直接为什么这套组合适合运输管理核心原因是业务中充满「多条件查询」和「报表统计」MyBatis 的 XML 写动态 SQL 比 JPA 直观。而且这个项目内部大量页面是服务端渲染的用 Thymeleaf 可以减少前后端联调成本拉起后端就能看完整页面这对于需要快速交付的内部管理工具是很大的优势。权限没有引 Shiro 或 Security而是用拦截器做登录校验角色判断放在 Service 层。这种取舍在中小项目里更常见——少一层框架就少一层黑匣子后续真要上功能更全的权限体系再换也不迟。2.2 目录结构按业务模块分包不是按层次分包Maven 工程的源码包是按业务模块划分的打开之后能看到清晰的边界tms/ ├── pom.xml ├── sql/ │ └── init.sql # 建库建表脚本 └── src/main/ ├── java/com/tms/ │ ├── TmsApplication.java # 启动类 │ ├── common/ # 公共层 │ │ ├── config/ # 配置MyBatis、WebMvc │ │ ├── interceptor/ # 登录拦截器 │ │ ├── result/ # Result 统一返回体 │ │ └── util/ # 日期、编号生成工具 │ ├── system/ # 系统管理模块 │ │ ├── controller/ # 登录、用户管理 │ │ ├── entity/ # SysUser 实体 │ │ ├── mapper/ # SysUserMapper │ │ └── service/ # 用户与登录服务 │ ├── base/ # 基础资料模块 │ │ ├── controller/ # 车辆、司机、客户 │ │ ├── entity/ │ │ ├── mapper/ │ │ └── service/ │ └── tms/ # 运输业务模块 │ ├── controller/ # 订单、运单、调度 │ ├── entity/ # 订单、运单、轨迹日志 │ ├── mapper/ │ └── service/ └── resources/ ├── application.yml # 核心配置 ├── mapper/ # MyBatis XML 文件 ├── static/ # AdminLTE、JS、CSS └── templates/ # Thymeleaf 页面分包思路是「一个业务模块一个包」而不是常见的 controller/service/mapper 三层大包。这样加一个新模块时直接把包复制一份改名字就能开工。实际开发时我一般会按业务包再配合 common 包放通用类避免多个模块互相引用造成循环依赖。2.3 数据库核心表订单、运单、车辆、司机之间如何关联这套系统的核心数据模型围绕「运输订单」和「运单」展开。订单是业务源头运单是实际执行单元车辆和司机挂在运单上。关键表设计大致如下表名关键字段作用base_vehicleid, plate_no, type, max_weight, max_volume, status, last_finish_time车辆档案status 0 空闲 / 1 在途 / 2 维修base_driverid, name, phone, license_type, status司机档案base_customerid, name, contact, phone, address发货客户tms_orderid, order_no, customer_id, from_address, to_address, goods_name, goods_weight, goods_volume, status运输订单status 0 待调度 / 1 已派车 / 2 在途 / 3 已签收 / 4 已取消tms_waybillid, waybill_no, order_id, vehicle_id, driver_id, plan_start_time, plan_end_time, status运单一辆车一次运输对应一条tms_waybill_logid, waybill_id, operate_type, operator_id, remark, create_time运单轨迹记录从表关联可以看出业务约束一个订单生成一张运单一张运单绑定一辆车和一名司机运单状态变化时同步写一条轨迹日志。这种「订单 运单 日志」的模型足够覆盖中小型运输公司的日常管理也是后续做计费、做成本核算时的数据基础。需要说明的是如果项目后续要支持一单多车或者多点卸货这个模型需要把运单改为运单明细表但当前这套设计在做状态流转时是最不容易翻车的。3. 核心模块拆解一个运单从创建到签收的完整链路运输系统的核心不在页面而在调度那一刻的状态流转。这一章我会从登录校验讲到调度派车再到签收看看每一个环节的代码是怎么把表里的状态一步一步推着走的。3.1 登录与权限拦截器如何拦截未登录请求系统没有引入安全框架而是自己写了一个LoginInterceptor。它做的事很简单检查 Session 里有没有登录用户没有就跳回登录页但需要注意 AJAX 请求的处理方式否则前端会莫名其妙地拿到一个 HTML 页面。Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录页和静态资源不拦截 String uri request.getRequestURI(); if (uri.startsWith(/login) || uri.startsWith(/static/)) { return true; } // 从 session 中取出登录时存入的用户对象 Object user request.getSession().getAttribute(loginUser); if (user null) { // 判断是否为 AJAX 请求Header 里有 X-Requested-With String xrw request.getHeader(X-Requested-With); if (XMLHttpRequest.equals(xrw)) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\会话过期请重新登录\}); } else { response.sendRedirect(/login); } return false; } return true; } }这段代码里值得关注的是两个分支的处理逻辑。普通页面请求直接重定向到/login让用户重新登录AJAX 请求则返回 401 状态码和 JSON前端收到之后可以统一弹提示并跳转登录页。很多初学项目只做了后者的重定向结果页面用 fetch 调接口时收到的是登录页 HTML解析 JSON 直接报错。拦截器要生效还需要在配置类里注册否则等于白写Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /static/**); } }excludePathPatterns必须把静态资源目录排除掉否则登录页加载 CSS、JS 也会被拦截浏览器控制台会刷出一堆 401。另一个需要注意的细节是Thymeleaf 模板里的静态资源引用路径要和这里的排除路径保持一致项目里统一用的是/static/前缀。3.2 调度派车订单转运单的 Service 核心逻辑调度是整个运输系统最关键的环节。前端页面上调度员选中一条待调度的订单点击「派车」后选择车辆和司机后端要做状态校验并生成运单。DispatchServiceImpl里有一段典型的业务代码Service public class DispatchServiceImpl implements DispatchService { Resource private TmsOrderMapper orderMapper; Resource private TmsWaybillMapper waybillMapper; Resource private BaseVehicleMapper vehicleMapper; Transactional(rollbackFor Exception.class) public Result dispatch(Long orderId, Long vehicleId, Long driverId) { // 1. 订单必须存在且处于待调度状态 TmsOrder order orderMapper.selectById(orderId); if (order null || order.getStatus() ! 0) { return Result.error(订单不存在或已调度); } // 2. 车辆必须存在且处于空闲状态 BaseVehicle vehicle vehicleMapper.selectById(vehicleId); if (vehicle null || vehicle.getStatus() ! 0) { return Result.error(车辆已被占用请重新选择); } // 3. 货重不能超过车辆额定载重 if (order.getGoodsWeight().compareTo(vehicle.getMaxWeight()) 0) { return Result.error(货物重量超出车辆载重限制); } // 4. 生成运单初始状态为已派车 TmsWaybill waybill new TmsWaybill(); waybill.setWaybillNo(generateWaybillNo()); waybill.setOrderId(order.getId()); waybill.setVehicleId(vehicleId); waybill.setDriverId(driverId); waybill.setStatus(1); waybillMapper.insert(waybill); // 5. 更新订单状态为已派车车辆状态置为占用 orderMapper.updateStatus(orderId, 1); vehicleMapper.updateStatus(vehicleId, 1); return Result.success(waybill.getId()); } }这段代码的逻辑顺序是有讲究的先校验订单再校验车辆最后校验载重。如果把载重校验放在订单校验之前遇到订单不存在的情况会返回一个容易误导调度员的错误消息。方法上加Transactional是必须的因为一旦运单插入成功但订单状态更新失败就会产生「有运单但订单还是待调度」的脏数据事务可以保证这五个步骤要么全成功要么全部回滚。值得留意的还有compareTo的用法。goodsWeight和maxWeight在数据库里用的是DECIMAL(10,2)对应 Java 的BigDecimal金额和重量这类数据不能用直接比较必须用compareTo否则超出载重时校验不会生效。3.3 智能推荐如何从空闲车辆里选出最合适的一辆调度页面除了手动选车还提供了一个「推荐车辆」的按钮。这个功能的实现并不复杂就是把满足条件的空闲车辆全部取出来按上一次完成任务的时间排个序选最近的。我用一个循环来做比 Stream 更直观public BaseVehicle recommendVehicle(TmsOrder order) { // 只取空闲车辆避免推荐到在途或维修车辆 ListBaseVehicle all vehicleMapper.selectByStatus(0); BaseVehicle best null; for (BaseVehicle v : all) { // 载重和容积必须同时满足 if (v.getMaxWeight().compareTo(order.getGoodsWeight()) 0) { continue; } if (v.getMaxVolume().compareTo(order.getGoodsVolume()) 0) { continue; } // 优先选最近完成任务空闲下来的车 if (best null || v.getLastFinishTime().isAfter(best.getLastFinishTime())) { best v; } } return best; }last_finish_time字段在车辆每次签收完成任务时更新所以「最近空闲」的语义就是最后完成时间最新。如果项目后续要加入车辆分区、司机技能等级这些维度只需要在这个循环里加continue条件即可。这个方案的优点是查询简单不会给 MySQL 增加压力缺点是如果车辆数据超过几千条每次都全表扫描会慢届时要考虑在status和last_finish_time上加联合索引。3.4 签收与轨迹运单状态如何闭环运输最后一环是客户签收。签收之后运单状态从在途变为已签收车辆被释放为空闲同时写一条轨迹日志方便后续追溯。public Result sign(Long waybillId) { TmsWaybill waybill waybillMapper.selectById(waybillId); if (waybill null || waybill.getStatus() ! 2) { return Result.error(运单状态不允许签收); } LocalDateTime now LocalDateTime.now(); // 更新运单状态为已签收并记录实际完成时间 waybillMapper.updateStatus(waybillId, 3, now); // 车辆释放为空闲更新最后完成时间 vehicleMapper.updateStatus(waybill.getVehicleId(), 0); vehicleMapper.updateLastFinishTime(waybill.getVehicleId(), now); // 记录轨迹日志 waybillLogService.log(waybillId, SIGN, 客户已签收); return Result.success(); }这个流程往往被初学者忽略一个点车辆状态的释放不能写在更新运单状态之前。如果先把车辆置为空闲但运单状态更新失败了就会出现车被释放了实际运单还在途的尴尬情况。这些都是血泪经验——一开始我把顺序写反过结果测试时发现运单状态没变车辆却已经被下一单派走了。3.5 报表统计按自然月汇总发货量运营人员要看的月度报表在 MyBatis 里可以这样写select idcountMonthly resultTypemap SELECT DATE_FORMAT(create_time, %Y-%m) AS month, COUNT(*) AS total_orders, SUM(CASE WHEN status 3 THEN 1 ELSE 0 END) AS finished_orders FROM tms_order WHERE create_time gt; #{start} AND create_time lt; #{end} GROUP BY DATE_FORMAT(create_time, %Y-%m) ORDER BY month /selectSQL 里用DATE_FORMAT把时间归到月份CASE WHEN计算已完成订单数。Controller 拿到ListMapString, Object以后转成 JSON 返回给前端图表组件页面上的柱状图就有了数据源。这类统计不适合把逻辑写在 Java 里循环计算MySQL 的聚合函数就是为这种场景设计的靠近数据源计算效率和可读性都好得多。4. 本地跑通从环境准备到完成第一单派车这一章是照做就能跑通的部分。把项目从源码变成可运行的系统一共就三步准备环境、改配置、启动并操作业务闭环。我按实际踩过的坑来写省得你走弯路。4.1 环境准备JDK、Maven、MySQL 的版本选择项目基于 JDK 8 开发建议直接用 JDK 8不要用 JDK 17 硬跑。虽然 Spring Boot 2.7 能在更高版本 JDK 上运行但你会遇到 java.xml.bind 包缺失一类的额外问题纯属给自己找不痛快。Maven 用 3.6 以上即可MySQL 用 5.7 或 8.0 都行。准备清单软件推荐版本用途JDK1.8编译和运行Maven3.6.3依赖管理和打包MySQL5.7 / 8.0数据存储IDEIDEA 2021开发调试4.2 配置修改数据库连接、日志路径、端口项目拿到手第一件事就是打开src/main/resources/application.yml把数据库连接改成你本机的信息。上电后最常见的报错就是连不上数据库配置里最容易漏的是时区和驱动参数server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/tms?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver pagehelper: helper-dialect: mysql reasonable: true logging: file: name: logs/tms.logserverTimezoneAsia/Shanghai是必须的。如果漏掉MySQL 8.x 的驱动会在连接时报 CST 时区错误。allowPublicKeyRetrievaltrue则是为了解决 MySQL 8.0 的caching_sha2_password认证插件在非 SSL 连接下出现的 public key retrieval 错误。characterEncodingutf8mb4保证存入的中文不乱码注意不是utf8utf8在 MySQL 里最多支持 3 字节存不了 emoji 和部分生僻字。数据库初始化用项目自带的 SQL 脚本mysql -uroot -p123456 sql/init.sql脚本会自动创建tms数据库和所有表并插入一个初始账号admin / 123456。执行完之后可以登录 MySQL 确认一下mysql -uroot -p123456 -e use tms; show tables;看到tms_order、tms_waybill这些表就说明初始化成功了。4.3 启动项目两种方式任选其一开发调试时用 Maven 插件启动最方便mvn spring-boot:run看到类似Tomcat started on port(s): 8080的日志就说明启动成功。如果想模拟生产环境部署就先打包再运行mvn clean package -DskipTests java -jar target/tms-0.0.1-SNAPSHOT.jar打包时如果遇到 Lombok 相关报错先检查 pom 里的 Lombok 版本用 1.18.30 基本不会出问题。启动成功后浏览器访问http://localhost:8080/login输入admin / 123456即可进入系统。4.4 跑通第一个业务闭环建客户、录订单、派车、签收登录之后按下面的顺序操作一遍就能验证整个系统运转是否正常在「基础资料-车辆」中添加一辆车填车牌号和载重状态保持空闲。在「基础资料-客户」中添加一个发货客户。在「订单管理」中新增订单选择客户填写起止地址、货物名称和重量。进入「调度管理」找到这条订单点击「派车」选择刚添加的车辆和一个司机提交。在「运单管理」中看到已生成的运单点击「发车」后再点击「签收」。从数据库观察状态变化tms_order.status从 0 变为 1最后变为 3base_vehicle.status从 0 变为 1 再回到 0tms_waybill_log里会多出两条日志。看到这种数据流转说明整个系统已经真正跑通了。5. 避坑排查部署这套系统时最容易踩的六个坑把项目部署到不同环境时有几个问题出现频率非常高。我把它们按「现象 → 原因 → 解决」的方式记录下来基本覆盖了环境差异导致的典型故障。5.1 数据库连接报错Communications link failure现象启动项目时报Cannot create PoolableConnectionFactory或Communications link failure数据库密码明明是对的。 原因九成是 MySQL 8.0 的认证插件问题。MySQL 8 默认使用caching_sha2_password而旧驱动不支持还有一部分是时区设置缺失。 解决pom.xml里确保驱动是com.mysql.cj.jdbc.Driver以及 8.0.33 左右的新版本连接串里加useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue三件套缺一个都可能出问题。5.2 页面中文全部是问号现象登录后页面上的中文显示成???或者数据库里存进去的中文取出来乱码。 原因建库的时候没有显式指定字符集。MySQL 在 Linux 环境下的默认字符集经常是latin1导致表里存不了中文。 解决执行ALTER DATABASE tms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后把已有的表也转成 utf8mb4。更彻底的做法是在sql/init.sql开头加上SET NAMES utf8mb4;以后初始化就不会再犯。5.3 Lombok 编译失败找不到 getter 和 setter现象mvn compile报错提示cannot find symbol - method getStatus()但实体类明明定义了private Integer status;。 原因Lombok 是新版 JDK 和旧版 Lombok 兼容问题或者是 IDE 没有启用注解处理。 解决将 Lombok 版本升级到 1.18.30 或以上在 IDEA 中确认设置了Annotation Processing为开启状态。JDK 8 搭配 Lombok 1.18.30 是最稳的组合。5.4 页面白标Whitelabel Error Page现象某些路径访问时返回空白页加一行错误信息控制台出现Error resolving template。 原因Controller 返回的视图名和templates目录下的 HTML 文件名对不上。比如 Controller 返回order/list但实际文件放在了static/下面而不是templates/order/list.html。 解决Thymeleaf 默认从templates/目录下解析视图检查返回值与文件路径的对应关系。我一般会在application.yml里临时设spring.thymeleaf.cachefalse这样修改页面后不用重启就能生效调试效率高很多。5.5 端口 8080 被占用现象启动时日志报Port 8080 was already in use项目起不来。 原因本机已经有别的进程占用了 8080比如之前残留的 java 进程或另一个服务。 解决先找到占用进程。Windows 下执行netstat -ano | findstr 8080记下 PID 后在任务管理器里结束它Linux 下用lsof -i:8080。如果不想动其他进程就在application.yml里把server.port改成 8081。5.6 登录页没有样式一片裸 HTML现象登录页能打开但所有 CSS、JS 都加载失败浏览器控制台全是 404。 原因静态资源路径没有对上。项目把 AdminLTE 相关文件放在static/目录下但 Thymeleaf 模板里有些链接写的是相对路径比如dist/css/adminlte.min.css少了/static/前缀。 解决统一把资源引用改成{/static/dist/css/adminlte.min.css}这种写法同时确保拦截器排除路径包含/static/**。这套项目里如果自己加新页面直接复制已有页面的头部引用不容易出错。6. 二次开发进阶把通用 TMS 改造成贴合自家业务的状态机跑通只是开始真正让它产生价值的是后续改造。从我拆过的项目经验看大多数物流公司拿到这套系统后第一件事是改运单编号规则第二件事是加业务报表第三件事是接入地图轨迹。这三个方向对应三个不同的改造深度。第一个改造点是运单号生成。现在的generateWaybillNo()用System.currentTimeMillis()拼前缀虽然单机没问题但并发一高就会出现重复单号。我一般会改成「日期 自增序列」的方式或者引入 Redis INCR 生成每日递增号比如WB20250101001。改在DispatchServiceImpl的调用处即可不影响其他业务逻辑。第二个改造点是增加路线和成本模块。现有的车辆表和运单表没有路线概念如果业务是按线路跑的可以加一张base_route表运单关联 route_id这样报表就能按线路汇总毛利润。车辆的成本油费、过路费、司机工资可以挂在运单行项目上车辆表只保留基础档案。改造时注意保持现有状态流转不变新增字段不要破坏dispatch方法里的载重校验逻辑。第三个改造点是地图轨迹回放。当前tms_waybill_log只记录了操作事件没有经纬度。要支持在途可视可以加一张位置上报表由司机端 App 定期上报坐标后台用高德或百度的 Web API 做逆地理编码把轨迹渲染到地图上。这一步不需要改动调度核心逻辑只做数据追加和展示适合在现有功能稳定后推进。这套系统的状态机值得琢磨订单有 0 待调度 / 1 已派车 / 2 在途 / 3 已签收 / 4 已取消运单有对应的状态车辆有独立的占用状态。三个状态必须联调才能保证数据不错。我做某运输项目时曾经在签收逻辑里只更新了运单状态忘了释放车辆导致调度员连续三天发现没有空闲车可派。从那以后我每次改调度模块都强制走一遍状态流转核对表订单状态、运单状态、车辆状态各有哪几条合法路径新增一个操作会不会产生非法状态。建议你拿到代码后也先画一张三态流转对照表哪怕只是写在纸上改代码时也能少走弯路。希望这份拆解能帮你更快跑通这套系统也希望你在改它的时候比当时的我少踩几个状态不同步的坑。本文还有配套的精品资源点击获取