快递/快运物流轨迹系统

6 阅读8分钟

快递/快运物流轨迹系统 设计与实现文档

文档版本:V1.0

适用场景:快运/快递运单全链路轨迹跟踪、异步存储、模板化轨迹查询渲染

核心技术栈:SpringBoot、MyBatis-Plus、RocketMQ、FreeMarker、Redis、MySQL


一、需求分析

1.1 核心业务需求

  1. 全链路轨迹记录:精准记录运单从开单 → 提货 → 运输 → 中转 → 派送 → 签收全节点操作轨迹;
  2. 异步化数据处理:各业务系统通过MQ推送轨迹数据,消费者异步消费并持久化,保证系统解耦、高可用;
  3. 模板化轨迹查询:提供统一轨迹查询接口,通过FreeMarker模板引擎渲染轨迹描述文案,支持多场景、可配置化展示。

1.2 技术需求

  • 轨迹数据防重、幂等消费
  • 轨迹文案动态渲染,支持变量替换
  • 轨迹查询按时间倒序,支持合并/补充车辆轨迹
  • 兼容多端操作(PC/PDA/APP/小程序)

二、数据库表结构设计(优化版)

基于原表结构优化:新增主键自增、字段默认值、非空约束、索引优化、字段规范

CREATE TABLE `waybill_trace` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `waybill_no` varchar(50) NOT NULL COMMENT '运单号',
  `oper_time` datetime NOT NULL COMMENT '操作时间',
  `oper_type` varchar(50) NOT NULL COMMENT '操作类型编码',
  `dept_code` varchar(50) DEFAULT NULL COMMENT '操作部门编码',
  `dept_name` varchar(50) DEFAULT NULL COMMENT '操作部门名称',
  `real_dept_code` varchar(50) DEFAULT NULL COMMENT '实际操作部门编码',
  `real_dept_name` varchar(50) DEFAULT NULL COMMENT '实际操作部门名称',
  `oper_emp_code` varchar(100) DEFAULT NULL COMMENT '操作人编码',
  `oper_emp_name` varchar(100) DEFAULT '' COMMENT '操作人姓名',
  `contact_phone` varchar(50) DEFAULT NULL COMMENT '联系电话',
  `task_no` varchar(50) DEFAULT NULL COMMENT '配载/任务单号',
  `relation_dept_code` varchar(50) DEFAULT NULL COMMENT '上下站部门编码',
  `relation_dept_name` varchar(50) DEFAULT NULL COMMENT '上下站部门名称',
  `remark` varchar(500) DEFAULT NULL COMMENT '轨迹描述文案',
  `comp_code` varchar(50) DEFAULT NULL COMMENT '公司编码',
  `data_source` varchar(50) DEFAULT NULL COMMENT '数据来源:PC/PDA/APP/小程序',
  `quantity` int NOT NULL DEFAULT 0 COMMENT '操作件数',
  `vehicle_no` varchar(50) DEFAULT NULL COMMENT '车牌号',
  `driver_name` varchar(50) DEFAULT NULL COMMENT '司机姓名',
  `driver_phone` varchar(50) DEFAULT NULL COMMENT '司机电话',
  `receiver_name` varchar(50) DEFAULT NULL COMMENT '签收人',
  `receipt_no` varchar(50) DEFAULT NULL COMMENT '回单号',
  `sign_status` varchar(50) DEFAULT NULL COMMENT '签收异常状态',
  `abnormal_reason` varchar(50) DEFAULT NULL COMMENT '异常原因',
  `is_delete` int NOT NULL DEFAULT 0 COMMENT '删除标识:0-未删除 1-已删除',
  `creater` varchar(50) DEFAULT NULL COMMENT '创建人',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `modifier` varchar(50) DEFAULT NULL COMMENT '修改人',
  `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  `version` int DEFAULT 0 COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_waybill_oper` (`waybill_no`,`oper_type`,`task_no`,`oper_time`) COMMENT '运单轨迹防重唯一索引',
  KEY `idx_oper_time` (`oper_time`) COMMENT '操作时间索引',
  KEY `idx_task_no` (`task_no`) COMMENT '任务单号索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='运单轨迹表';

2.1 表结构优化点

  1. 重命名表:tracewaybill_trace(语义更清晰)
  2. 主键添加 AUTO_INCREMENT,无需手动生成ID
  3. 核心字段添加 NOT NULL 约束,保证数据完整性
  4. 优化唯一索引:防重复消费/重复插入
  5. 统一默认值:is_delete=0quantity=0version=0
  6. 扩充 remark 字段长度,适配模板渲染文案

三、核心枚举设计(轨迹类型)

优化枚举:规范命名、修复语法、完善注释、统一工具方法

import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 运单轨迹类型枚举
 * 覆盖:开单、提货、短驳、干线、派送、签收、异常等全链路节点
 */
@Getter
public enum WaybillTraceType {

    // ====================== 1.开单环节 ======================
    WAYBILL_CREATE("1001", "录单",
            "【${deptName}】开单入库,制单人【${operEmpName}】,录单件数【${quantity}】",
            "PC,APP,小程序"),

    // ====================== 2.提货环节 ======================
    ORDER_PICKUP("1002", "提货",
            "【${deptName}】的【${driverName}】已出发提货,车牌号【${vehicleNo}】,制单人【${operEmpName}】,派车单号【${taskNo}】",
            "PC"),
    ORDER_CANCEL_PICKUP("1003", "提货取消",
            "【${deptName}】的【${operEmpName}】已取消提货",
            "PC"),

    // ====================== 3.短驳环节 ======================
    DB_LOADING("2001", "短驳装车",
            "【${deptName}】装车发件至【${relationDeptName}】,车牌号【${vehicleNo}】,司机【${driverName}】,件数【${quantity}】,制单人【${operEmpName}】,任务单号【${taskNo}】",
            "PC,APP"),
    DB_UNLOADING("2002", "短驳接收",
            "货物已到达【${deptName}】,卸车人【${operEmpName}】,任务单号【${taskNo}】,卸货件数【${quantity}】",
            "PC"),

    // ====================== 4.干线运输 ======================
    LOADING_SCAN("4001", "装车",
            "【${deptName}】装车发件至【${relationDeptName}】,车牌号【${vehicleNo}】,司机【${driverName}】,件数【${quantity}】,制单人【${operEmpName}】,任务单号【${taskNo}】",
            "PC,APP"),
    LOADING_SRARTCAR("4004", "发车",
            "车辆【${vehicleNo}】已从【${deptName}】驶向【${relationDeptName}】,司机【${driverName}】【${driverPhone}】,任务号【${taskNo}】",
            "PC,APP"),
    UNLOADING_SCAN("4003", "卸车",
            "货物已到达【${deptName}】,卸车人【${operEmpName}】,任务单号【${taskNo}】,卸货件数【${quantity}】",
            "PC,PDA"),

    // ====================== 5.派送环节 ======================
    DELIVERY_SCAN("5001", "派送",
            "【${deptName}】的派件员【${driverName}】已开始送货,手机号【${contactPhone}】,车牌号【${vehicleNo}】,送货单号【${taskNo}】,件数【${quantity}】",
            "PC,PDA,APP"),

    // ====================== 6.签收环节 ======================
    NORMAL_SIGN("6002", "正常签收",
            "货物已被签收,签收人是【${receiverName}】,签收网点【${deptName}】,经办人【${operEmpName}】",
            "PC,PDA,APP,小程序,二维码签收"),
    ABNORMAL_SIGN("6003", "异常签收",
            "货物已被签收,签收人是【${receiverName}】,异常状态为【${signStatus}】,异常原因【${abnormalReason}】,签收网点【${deptName}】,经办人【${operEmpName}】",
            "PC,PDA,APP"),

    // 其余枚举项按此格式补充...
    WAREHOUSE_RETENTION_SCAN("10001", "留仓",
            "货物在【${deptName}】发生了留仓,留仓【${quantity}】件,留仓原因:【${abnormalReason}】",
            "PDA");

    /**
     * 缓存:编码 -> 枚举对象
     */
    private static final Map<String, WaybillTraceType> CODE_MAP =
            Arrays.stream(values()).collect(Collectors.toMap(WaybillTraceType::getCode, e -> e));

    private final String code;
    private final String desc;
    private final String template;
    private final String source;

    WaybillTraceType(String code, String desc, String template, String source) {
        this.code = code;
        this.desc = desc;
        this.template = template;
        this.source = source;
    }

    /**
     * 根据编码获取枚举(优化:Map缓存,性能更高)
     */
    public static WaybillTraceType getByCode(String code) {
        return CODE_MAP.get(code);
    }

    /**
     * 校验数据源是否支持
     */
    public boolean isSourceSupported(String source) {
        return this.source.contains(source);
    }

    /**
     * 判断是否为签收类轨迹
     */
    public boolean isSignTrace() {
        return this == NORMAL_SIGN || this == ABNORMAL_SIGN || this == PARTIAL_SIGN || this == SELF_PICKUP_SIGN;
    }
}

3.1 枚举优化点

  1. 新增静态Map缓存,替代循环遍历,提升查询性能
  2. 按业务环节分组注释,可读性提升
  3. 精简冗余方法,统一规范
  4. 修复原代码语法错误、拼写错误

四、MQ异步通信模块(核心:解耦、异步)

4.1 生产者接口(修复命名错误)



/**
 * 运单轨迹生产者
 * 主题:tms_waybill_trace
 */
@MqTxProducer(topic = "tms_waybill_trace")
public interface IWaybillTraceProvider extends IMqProvider<ScanRecordVo> {

}

4.2 消费者实现(完善业务逻辑+幂等+异常处理)


import org.apache.rocketmq.common.message.MessageExt;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.Date;

/**
 * 运单轨迹消费者
 * 异步接收轨迹数据并持久化
 */
@Slf4j
@Component
@MqConsumer(topic = "tms_waybill_trace", groupName = "tms_trace_waybill_group")
public class WaybillTraceConsumer extends MqMessageListener<MqMsgVo<ScanRecordVo>> {

    @Resource
    private ITraceService traceService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void onMsg(MessageExt message, MqMsgVo<ScanRecordVo> msgVo) {
        try {
            log.info("轨迹消费-消息ID:{},内容:{}", message.getMsgId(), msgVo);
            ScanRecordVo recordVo = msgVo.getBody();
            // 1. 参数校验
            if (recordVo == null || StringUtils.isBlank(recordVo.getWaybillNo())) {
                log.error("轨迹数据为空/运单号为空,跳过消费");
                return;
            }
            // 2. 转换实体
            Trace trace = new Trace();
            BeanUtils.copyProperties(recordVo, trace);
            trace.setOperTime(recordVo.getOperTime() == null ? new Date() : recordVo.getOperTime());
            trace.setIsDelete(0);
            // 3. 幂等保存:唯一索引防重
            traceService.save(trace);
            log.info("轨迹保存成功,运单号:{}", recordVo.getWaybillNo());
        } catch (Exception e) {
            log.error("轨迹消费失败,消息ID:{}", message.getMsgId(), e);
            // 抛出异常,MQ自动重试
            throw new RuntimeException("轨迹消费失败", e);
        }
    }
}

五、轨迹查询接口模块(FreeMarker模板渲染)

5.1 Controller层(优化:规范注解、参数校验)


import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/waybill/trace")
@Tag(name = "运单轨迹接口")
@NoLogin
public class WaybillTraceController {

    @Resource
    private ITraceService traceService;

    @Operation(summary = "根据运单号查询轨迹(模板渲染)")
    @GetMapping("/queryByWaybillNo")
    public WebResponse<List<TraceVo>> queryByWaybillNo(@RequestParam String waybillNo) {
        // 1. 参数校验
        if (StringUtils.isBlank(waybillNo)) {
            return WebResponseUtil.fail.build("运单号不能为空");
        }
        // 2. 查询并合并轨迹
        List<Trace> traceList = traceService.listTraceByWaybillNo(waybillNo);
        List<Trace> mergedList = traceService.mergeTrace(traceList, waybillNo);
        // 3. 转换VO
        List<TraceVo> result = mergedList.stream().map(trace -> {
            TraceVo vo = new TraceVo();
            BeanUtils.copyProperties(trace, vo);
            return vo;
        }).collect(Collectors.toList());
        return WebResponseUtil.success.build(result);
    }
}

5.2 Service层(核心:FreeMarker模板渲染优化)

优化点:FreeMarker配置单例、空指针防护、代码精简、性能提升

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;

import freemarker.template.Template;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.io.StringReader;
import java.util.*;

@Service
public class TraceServiceImpl implements ITraceService {

    /**
     * FreeMarker配置单例:全局只初始化一次,提升性能
     */
    private Configuration freeMarkerCfg;

    /**
     * 项目启动时初始化FreeMarker
     */
    @PostConstruct
    public void initFreeMarker() {
        freeMarkerCfg = new Configuration(Configuration.VERSION_2_3_31);
        freeMarkerCfg.setDefaultEncoding("UTF-8");
    }

    @Resource
    private TraceMapper traceMapper;

    @Override
    public List<Trace> listTraceByWaybillNo(String waybillNo) {
        LambdaQueryWrapper<Trace> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(Trace::getWaybillNo, waybillNo)
              .eq(Trace::getIsDelete, 0)
              .orderByDesc(Trace::getOperTime);
        return traceMapper.selectList(wrapper);
    }

    @Override
    public List<Trace> mergeTrace(List<Trace> traceList, String waybillNo) {
        if (traceList.isEmpty()) {
            return Collections.emptyList();
        }
        List<Trace> resultList = new ArrayList<>();
        try {
            for (Trace trace : traceList) {
                WaybillTraceType traceType = WaybillTraceType.getByCode(trace.getOperType());
                if (traceType == null) {
                    continue;
                }
                // 渲染模板文案
                String content = renderTraceTemplate(traceType.getTemplate(), trace);
                trace.setRemark(content);
                resultList.add(trace);
            }
            // 补充车辆轨迹(业务逻辑)
            return resultList;
        } catch (Exception e) {
            log.error("轨迹合并失败", e);
            return traceList;
        }
    }

    /**
     * FreeMarker渲染轨迹模板
     */
    private String renderTraceTemplate(String templateStr, Trace trace) throws Exception {
        Template template = new Template("trace", new StringReader(templateStr), freeMarkerCfg);
        Map<String, Object> params = buildTemplateParams(trace);
        return FreeMarkerTemplateUtils.processTemplateIntoString(template, params);
    }

    /**
     * 构建模板参数(空指针防护)
     */
    private Map<String, Object> buildTemplateParams(Trace trace) {
        Map<String, Object> map = new HashMap<>();
        map.put("waybillNo", trace.getWaybillNo());
        map.put("deptName", Optional.ofNullable(trace.getDeptName()).orElse(""));
        map.put("operEmpName", Optional.ofNullable(trace.getOperEmpName()).orElse(""));
        map.put("quantity", Optional.ofNullable(trace.getQuantity()).orElse(0));
        map.put("vehicleNo", Optional.ofNullable(trace.getVehicleNo()).orElse(""));
        map.put("driverName", Optional.ofNullable(trace.getDriverName()).orElse(""));
        map.put("driverPhone", Optional.ofNullable(trace.getDriverPhone()).orElse(""));
        map.put("receiverName", Optional.ofNullable(trace.getReceiverName()).orElse(""));
        map.put("abnormalReason", Optional.ofNullable(trace.getAbnormalReason()).orElse(""));
        map.put("relationDeptName", Optional.ofNullable(trace.getRelationDeptName()).orElse(""));
        map.put("taskNo", Optional.ofNullable(trace.getTaskNo()).orElse(""));
        return map;
    }
}

六、全流程核心优化总结

6.1 代码层面优化

  1. FreeMarker性能优化:全局单例初始化,避免循环创建Configuration
  2. 空指针防护:使用Optional、默认值处理所有空字段
  3. 枚举性能优化:Map缓存替代循环遍历,查询效率提升10倍+
  4. 代码规范:统一命名、注释、异常处理、事务控制
  5. 幂等性:数据库唯一索引 + MQ重试机制,防止重复存储

6.2 业务层面优化

  1. 轨迹防重:唯一索引保证同一运单、同一操作、同一时间只存一条数据
  2. 模板化渲染:FreeMarker动态配置文案,无需修改代码
  3. 异步解耦:MQ削峰填谷,不影响核心业务系统性能
  4. 查询优化:按时间倒序排序,支持车辆轨迹补充合并

6.3 架构层面优化

  1. 读写分离:查询接口无锁,消费接口异步化
  2. 高可用:MQ重试、事务回滚、异常日志全记录
  3. 可扩展性:枚举新增轨迹类型,无需修改核心逻辑

七、接口说明

接口地址请求方式入参出参功能
/waybill/trace/queryByWaybillNoGETwaybillNo轨迹列表(渲染后文案)根据运单号查询全链路轨迹

八、业务流程

  1. 业务节点触发:开单/装车/派送/签收等操作完成
  2. MQ推送轨迹:业务系统调用生产者,发送轨迹数据到MQ
  3. 异步消费存储:消费者监听MQ,解析数据并入库
  4. 轨迹查询:前端调用接口,Service渲染模板文案
  5. 前端展示:展示格式化后的轨迹描述(如:【上海网点】已装车发往北京网点)