多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Java后端TMS物流运输系统实战:运单状态机、调度派单与计费规则设计

Java后端TMS物流运输系统实战:运单状态机、调度派单与计费规则设计 简介本资源为基于Java开发的TMS物流运输系统后端设计源码面向具备一定Java基础、希望深入理解企业级物流系统架构的开发者与学习者。项目围绕订单管理、运输路线规划、货物跟踪等核心业务展开涵盖调度、结算、车队管理、网关及通用模块可用于学习后端分层设计与业务逻辑实现。压缩包共193个文件约536KB其中144个Java源码文件承载主要业务逻辑28个XML与10个YAML文件负责框架及环境配置另含JAR打包文件、Properties配置、Markdown说明文档以及mvnw构建脚本结构完整、便于部署与二次开发。目前已有635人学习下载。通过研读源码读者可掌握TMS系统从订单委托、调度派发到结算处理的完整链路理解配置管理与项目构建方式并借鉴其并发处理与模块划分思路为物流类后端项目开发提供可复用的参考。1. 从一张运单说起Java 后端在 TMS 物流运输系统里到底扛了什么一张运单从货主下单到签收回单中间要经过调度分配、承运商确认、司机接单、在途轨迹上报、到达卸货、回单上传、对账结算至少七个状态节点。每个节点都可能出现改单、取消、拆分、合单、异常滞留。用 Excel 加微信群撑到日均三百单还行过了这个量级调度员会开始怀疑人生——运单状态对不上、运费算错、司机说没收到派单、财务说回单少了两张。TMSTransportation Management System物流运输系统后端要解决的核心问题就一句话让每一张运单的状态流转可追溯、可计算、可对账。用 Java 做这套后端不是因为 Java 时髦而是这个场景天然需要强类型、事务边界清晰、生态里现成的状态机、规则引擎、定时调度、消息队列组件足够成熟。热搜里「java面试题」「mybatis源码」「spring boot mybatis」这些词频繁出现说明大量从业者正在用 Spring Boot MyBatis 这套组合做业务系统TMS 恰好是这套技术栈最典型的落地场景之一。这篇文章面向两类人一是手里有 TMS 后端源码、想读懂架构再动手改的开发者二是准备从零搭一套 TMS 后端、需要知道表怎么设计、状态怎么流转、运费怎么算的工程师。下面按「领域模型 → 核心链路 → 落地实现 → 避坑 → 进阶」的顺序拆开讲每一步都落到能抄的代码和参数上。2. TMS 后端的领域模型与表结构先想清楚运单、运力、计费三件事2.1 运单主表与状态机的字段设计TMS 后端最容易翻车的地方不是代码写得多烂而是表结构一开始就没把「状态」和「状态变更记录」分开。常见做法是把status字段直接放在运单主表上改一次状态就 update 一次结果出了问题查不到是谁在什么时间改的、改之前是什么状态。我一般会拆成两张表tms_waybill存当前快照tms_waybill_status_log存每一次流转记录。-- 运单主表只存当前状态快照 CREATE TABLE tms_waybill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL COMMENT 运单号业务唯一键, customer_id BIGINT NOT NULL COMMENT 货主ID, carrier_id BIGINT COMMENT 承运商ID调度后回填, driver_id BIGINT COMMENT 司机ID接单后回填, origin_code VARCHAR(16) NOT NULL COMMENT 起运地行政区划码, dest_code VARCHAR(16) NOT NULL COMMENT 目的地行政区划码, cargo_weight DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT 货物重量(kg), cargo_volume DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT 货物体积(m³), freight_amount DECIMAL(12,2) COMMENT 运费金额计费后回填, status TINYINT NOT NULL DEFAULT 10 COMMENT 当前状态码, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_waybill_no (waybill_no), KEY idx_customer_status (customer_id, status), KEY idx_carrier_status (carrier_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT运单主表; -- 状态流转日志只追加不修改 CREATE TABLE tms_waybill_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, from_status TINYINT NOT NULL COMMENT 变更前状态, to_status TINYINT NOT NULL COMMENT 变更后状态, operator_id BIGINT NOT NULL COMMENT 操作人ID, operator_type TINYINT NOT NULL COMMENT 1-货主 2-调度 3-司机 4-系统, remark VARCHAR(255) COMMENT 变更备注, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_waybill_no (waybill_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT运单状态流转日志;逻辑说明主表用version字段做乐观锁防止两个调度员同时抢一张运单派给不同司机。状态日志表只 insert 不 update任何状态变更都必须先写日志再改主表顺序反了就会出现「主表状态变了但日志没记上」的黑匣子。参数上status用 TINYINT 而不是 VARCHAR是因为状态码在代码里要参与条件判断和索引字符串比较在百万级运单下会明显拖慢查询。origin_code和dest_code用行政区划码而不是自由文本是为了后续按区域做运力匹配和线路报价。2.2 运力资源表与司机承运商的关系运力侧要回答的问题是谁有车、车能拉多少、司机归哪个承运商管、司机当前是否可接单。常见做法是拆成tms_carrier承运商、tms_driver司机、tms_vehicle车辆三张表司机和车辆是多对多关系用一张tms_driver_vehicle关联表维护。CREATE TABLE tms_driver ( id BIGINT PRIMARY KEY AUTO_INCREMENT, carrier_id BIGINT NOT NULL COMMENT 所属承运商, name VARCHAR(32) NOT NULL, phone VARCHAR(16) NOT NULL, id_card_no VARCHAR(32) COMMENT 证件号脱敏存储, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-空闲 2-在途 3-休息 4-停用, max_load_kg DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT 可承接最大重量, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone), KEY idx_carrier_status (carrier_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT司机表;逻辑说明status字段是调度匹配的核心过滤条件派单时只查status1的司机。max_load_kg用来做重量匹配避免把 30 吨的货派给一辆限载 10 吨的车。注意id_card_no这类敏感字段生产环境要么加密存储要么只存后四位源码里如果直接明文存上线前必须改掉这是合规红线。2.3 计费规则表运费不是算出来的是配出来的运费计算是 TMS 后端最容易被低估的模块。不同客户、不同线路、不同货物类型计费方式可能完全不同有的按重量有的按体积有的按趟次有的按重量加体积取大值。硬编码 if-else 写到第三十个客户就会失控。我一般用一张tms_freight_rule表把规则配置化。字段类型说明rule_idBIGINT规则主键customer_idBIGINT适用货主0 表示通用origin_codeVARCHAR(16)起运地支持前缀匹配dest_codeVARCHAR(16)目的地支持前缀匹配charge_typeTINYINT1-按重量 2-按体积 3-按趟次 4-取大值unit_priceDECIMAL(10,4)单价min_chargeDECIMAL(10,2)最低消费effective_fromDATETIME生效时间effective_toDATETIME失效时间逻辑说明计费时按customer_id精确匹配优先、origin_code最长前缀匹配次之的顺序选规则取effective_from now effective_to的那条。charge_type4时分别算重量费和体积费取大值。这套设计的好处是运营改价格不用发版坏处是规则冲突时排查麻烦所以每次计费必须把命中的rule_id记到运单上方便对账时回溯。3. 核心链路落地下单、调度、在途、结算四段代码怎么写3.1 下单接口幂等、校验、落库三步不能少下单是 TMS 后端的入口也是最容易出重复单的地方。货主网络抖动重试一次后端就多一张运单调度员看到两张一样的单会直接骂人。幂等键用customer_id 外部单号做唯一索引插入冲突就返回已有运单。Service public class WaybillCreateService { Autowired private WaybillMapper waybillMapper; Autowired private WaybillStatusLogMapper statusLogMapper; Transactional(rollbackFor Exception.class) public String createWaybill(WaybillCreateCmd cmd) { // 1. 幂等校验外部单号已存在直接返回 Waybill exist waybillMapper.selectByCustomerAndOuterNo( cmd.getCustomerId(), cmd.getOuterNo()); if (exist ! null) { return exist.getWaybillNo(); } // 2. 基础校验起终点不能为空重量体积不能为负 if (StringUtils.isBlank(cmd.getOriginCode()) || StringUtils.isBlank(cmd.getDestCode())) { throw new BizException(起运地或目的地为空); } if (cmd.getCargoWeight().compareTo(BigDecimal.ZERO) 0) { throw new BizException(货物重量不能为负); } // 3. 生成运单号并落库 String waybillNo TMS System.currentTimeMillis() String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); Waybill waybill new Waybill(); waybill.setWaybillNo(waybillNo); waybill.setCustomerId(cmd.getCustomerId()); waybill.setOriginCode(cmd.getOriginCode()); waybill.setDestCode(cmd.getDestCode()); waybill.setCargoWeight(cmd.getCargoWeight()); waybill.setCargoVolume(cmd.getCargoVolume()); waybill.setStatus(WaybillStatus.CREATED.getCode()); // 10 waybillMapper.insert(waybill); // 4. 写状态日志 statusLogMapper.insert(buildLog(waybillNo, WaybillStatus.NONE.getCode(), WaybillStatus.CREATED.getCode(), cmd.getOperatorId(), 货主下单)); return waybillNo; } }逻辑说明Transactional保证运单主表和状态日志表要么都成功要么都回滚。幂等校验放在最前面避免重复插入触发唯一索引异常。运单号用时间戳加随机数生产环境如果并发量高建议换成雪花算法或数据库序列时间戳在毫秒级并发下仍有碰撞可能。参数上WaybillStatus.CREATED对应状态码 10这个码值要和前端、调度端约定一致改码值等于改协议必须走版本管理。3.2 调度派单乐观锁抢单与运力匹配调度派单的本质是「把一张待派运单分配给一个可用司机」。多个调度员同时操作时必须防止一张单被派两次。用主表的version字段做乐观锁update 时带上版本号影响行数为 0 就说明被别人抢先了。public boolean dispatch(String waybillNo, Long driverId, Long operatorId) { // 1. 查运单当前状态必须是待调度 Waybill waybill waybillMapper.selectByNo(waybillNo); if (waybill.getStatus() ! WaybillStatus.CREATED.getCode()) { throw new BizException(运单当前状态不可调度); } // 2. 查司机是否空闲且载重足够 Driver driver driverMapper.selectById(driverId); if (driver.getStatus() ! DriverStatus.IDLE.getCode()) { throw new BizException(司机当前不可接单); } if (driver.getMaxLoadKg().compareTo(waybill.getCargoWeight()) 0) { throw new BizException(司机载重不足); } // 3. 乐观锁更新运单 int rows waybillMapper.updateDispatch(waybillNo, driverId, driver.getCarrierId(), WaybillStatus.DISPATCHED.getCode(), waybill.getVersion()); if (rows 0) { throw new BizException(运单已被其他调度员派单); } // 4. 更新司机状态为在途 driverMapper.updateStatus(driverId, DriverStatus.ON_ROAD.getCode()); // 5. 写状态日志 statusLogMapper.insert(buildLog(waybillNo, WaybillStatus.CREATED.getCode(), WaybillStatus.DISPATCHED.getCode(), operatorId, 调度派单给司机 driverId)); return true; }逻辑说明updateDispatch的 SQL 里WHERE waybill_no ? AND version ?版本号不匹配就更新不到这是乐观锁的标准写法。参数上WaybillStatus.DISPATCHED对应状态码 20DriverStatus.ON_ROAD对应 2。注意司机状态更新和运单更新不在同一个事务里会有不一致风险实际项目里要么加分布式事务要么用本地消息表补偿源码里如果只是简单 update高并发下会出现「运单派了但司机还是空闲」的脏数据。3.3 在途轨迹上报高频写入怎么不拖垮数据库司机端每隔几十秒上报一次位置一张运单在途几小时就是几百条轨迹。直接往 MySQL 写单表很快到千万级查询和写入都会变慢。常见做法是轨迹先写消息队列再由消费端批量落库或者直接写时序数据库。如果源码里用的是 MySQL至少要做两件事按运单号分表或者按时间分区。RestController RequestMapping(/api/track) public class TrackController { Autowired private KafkaTemplateString, String kafkaTemplate; PostMapping(/report) public ResultVoid report(RequestBody TrackReportCmd cmd) { // 轨迹不直接落库先发消息队列削峰 String key cmd.getWaybillNo(); String value JSON.toJSONString(cmd); kafkaTemplate.send(tms-track-topic, key, value); return Result.ok(); } }逻辑说明用waybillNo做 Kafka 的 key保证同一张运单的轨迹进同一分区消费端可以按运单顺序处理。参数上tms-track-topic的分区数建议按日均轨迹量除以单分区承载量来定一般单分区每秒几千条没问题。消费端批量攒够 500 条或每 2 秒 flush 一次用INSERT INTO ... VALUES (...),(...)批量插入比逐条插入快一个数量级。如果源码里没有消息队列直接写库先加索引idx_waybill_time (waybill_no, report_time)再考虑分表。3.4 结算对账运费计算与回单核销结算段要做两件事算运费、核销回单。运费按第 2.3 节的规则表算回单核销是确认司机上传的回单和运单匹配。对账时最怕的是「运费算出来和客户预期不一致」所以每次计算都要把命中的规则和计算过程记下来。public BigDecimal calculateFreight(Waybill waybill) { // 1. 按货主线路匹配计费规则 FreightRule rule ruleMapper.matchRule( waybill.getCustomerId(), waybill.getOriginCode(), waybill.getDestCode()); if (rule null) { throw new BizException(未匹配到计费规则); } // 2. 按计费类型计算 BigDecimal amount; switch (rule.getChargeType()) { case 1: // 按重量 amount waybill.getCargoWeight().multiply(rule.getUnitPrice()); break; case 2: // 按体积 amount waybill.getCargoVolume().multiply(rule.getUnitPrice()); break; case 3: // 按趟次 amount rule.getUnitPrice(); break; case 4: // 取大值 BigDecimal byWeight waybill.getCargoWeight().multiply(rule.getUnitPrice()); BigDecimal byVolume waybill.getCargoVolume().multiply(rule.getUnitPrice()); amount byWeight.max(byVolume); break; default: throw new BizException(未知计费类型); } // 3. 最低消费兜底 if (amount.compareTo(rule.getMinCharge()) 0) { amount rule.getMinCharge(); } // 4. 记录命中的规则方便对账回溯 waybillMapper.updateFreight(waybill.getWaybillNo(), amount, rule.getRuleId()); return amount; }逻辑说明matchRule的 SQL 按customer_id精确匹配优先、origin_code和dest_code前缀匹配次之排序取第一条。参数上unit_price用 DECIMAL(10,4) 是为了支持「每公斤 0.35 元」这种精度用 float 会出现 0.10.2 不等于 0.3 的经典问题。min_charge兜底逻辑不能省否则短途单算出来运费几块钱连油费都不够。4. 避坑与排查TMS 后端上线后最常被叫去救火的五件事4.1 运单状态对不上日志表却是空的现象调度端显示运单已派单司机端显示待接单查状态日志表发现最后一条还是「货主下单」。原因状态变更代码里先 update 主表再 insert 日志中间抛异常导致日志没写进去或者两个操作不在同一事务里。解决把状态变更封装成一个统一方法强制「先写日志再改主表」并且加Transactional。如果已经出现脏数据用主表当前状态反推补一条日志备注写「数据修复」。4.2 运费算出来和客户合同差几块钱现象财务对账时发现某张运单运费比合同少 3.5 元。原因计费规则匹配到了旧版本的unit_price或者effective_to边界处理有问题now effective_to写成了now effective_to导致失效当天的单子用了旧价。解决规则匹配 SQL 里时间边界统一用左闭右开并且每次计费把rule_id和unit_price快照到运单上对账时直接比对快照而不是重新算。4.3 司机状态卡在「在途」新单派不出去现象司机明明已经签收状态还是「在途」调度派单时提示「司机当前不可接单」。原因签收接口只更新了运单状态忘了把司机状态改回「空闲」或者更新司机状态时抛了异常被吞掉。解决签收逻辑里把「运单签收」和「司机置闲」放在同一事务并且加一个定时任务每天凌晨扫描超过 24 小时仍在途的司机人工确认后强制置闲。4.4 轨迹表写入变慢接口超时现象司机端上报轨迹接口 P99 从 200ms 涨到 3s。原因轨迹表没有按时间分区单表数据量过亿insert 时索引维护开销变大。解决短期加idx_waybill_time覆盖索引长期按report_time做月度分区或者把轨迹迁到时序数据库。如果源码里轨迹和运单同库先把轨迹表拆到独立库避免拖累运单查询。4.5 并发下单出现重复运单号现象两个货主同一毫秒下单运单号后四位随机数相同唯一索引冲突报错。原因运单号生成用时间戳加四位随机数并发高时碰撞概率不可忽略。解决换成雪花算法或者用数据库序列表tms_sequence每次UPDATE ... SET current_val current_val 1再取虽然多一次数据库交互但绝对不重复。源码里如果是时间戳方案上线前压测一下并发下单碰撞率超过万分之一就得换。5. 进阶技巧用状态机引擎把流转规则从代码里抽出来前面几章的状态流转都是硬编码 if-else运单状态少的时候没问题状态一多、流转条件一复杂代码就会变成一坨。我一般会在项目中期引入轻量状态机把「什么状态可以转到什么状态、需要什么条件」配置化。Spring StateMachine 是常见选择但配置偏重小项目用枚举加转移表更轻。public enum WaybillStatus { NONE(0), CREATED(10), DISPATCHED(20), ACCEPTED(30), PICKED_UP(40), IN_TRANSIT(50), ARRIVED(60), SIGNED(70), SETTLED(80), CANCELLED(99); private final int code; WaybillStatus(int code) { this.code code; } public int getCode() { return code; } // 允许的流转关系key 是当前状态value 是可达状态集合 private static final MapWaybillStatus, SetWaybillStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(CREATED, EnumSet.of(DISPATCHED, CANCELLED)); TRANSITIONS.put(DISPATCHED, EnumSet.of(ACCEPTED, CANCELLED)); TRANSITIONS.put(ACCEPTED, EnumSet.of(PICKED_UP, CANCELLED)); TRANSITIONS.put(PICKED_UP, EnumSet.of(IN_TRANSIT)); TRANSITIONS.put(IN_TRANSIT, EnumSet.of(ARRIVED)); TRANSITIONS.put(ARRIVED, EnumSet.of(SIGNED)); TRANSITIONS.put(SIGNED, EnumSet.of(SETTLED)); } public static boolean canTransfer(WaybillStatus from, WaybillStatus to) { SetWaybillStatus targets TRANSITIONS.get(from); return targets ! null targets.contains(to); } }逻辑说明TRANSITIONS用 EnumSet 存可达状态canTransfer在每次状态变更前调用不满足直接抛异常。这样新增状态或调整流转关系只改这一张表不用满项目找 if-else。参数上状态码 10 到 80 每 10 一个间隔留出插入空间比如以后要在「已接单」和「已提货」之间加「已到达装货点」可以用 35。注意CANCELLED设为 99 而不是 90是为了和正常流转区分开查询时status 90就是有效运单。验证状态机是否生效最直接的办法是写单元测试覆盖所有非法流转。Test public void testIllegalTransition() { // 已签收的运单不能再取消 assertFalse(WaybillStatus.canTransfer( WaybillStatus.SIGNED, WaybillStatus.CANCELLED)); // 已创建的运单可以取消 assertTrue(WaybillStatus.canTransfer( WaybillStatus.CREATED, WaybillStatus.CANCELLED)); // 已提货的运单不能回到已接单 assertFalse(WaybillStatus.canTransfer( WaybillStatus.PICKED_UP, WaybillStatus.ACCEPTED)); }这套状态机加单元测试的组合我在三个 TMS 项目里都用过最大的好处是新人接手时不用问「这个状态能不能改」看枚举定义就清楚了。血泪经验是状态机一定要在项目早期引入等到三十个 if-else 写完再重构测试用例能写到你怀疑人生。另外状态变更日志的from_status和to_status必须从状态机里取不要手写数字手写迟早写错。最后说一个我自己的习惯每次上线新状态或改流转规则前先把生产库的运单状态分布拉出来看一眼确认没有卡在中间状态的异常单否则新规则一上那些异常单可能永远流转不下去。这个习惯帮我省过至少两次半夜回滚。希望帮到你。本文还有配套的精品资源点击获取
返回列表