基于Spring Boot的TMS运输管理系统:状态机、派车锁与权限隔离

简介:TMS运输管理系统.zip面向物流运输企业、Java后台开发学习者及需要运输管理信息化方案的团队,提供一套以Java构建的后台管理系统参考实现,重点解决订单流转、路线规划、车辆司机调度、成本控制与货物追踪等业务问题。资源围绕运输管理场景组织,涉及订单全程状态跟踪、最优路线计算、动态资源分配、运输成本分析、GPS货物定位、绩效报表以及与ERP/WMS等系统的接口集成等模块,适合用于理解运输管理系统的业务架构与后台实现思路。压缩包约74.39MB,文件总数记录为0、主要文件类型明细暂未提供,具体内容以上游解压结果为准;描述中提及的KYTMS子目录可作为查看系统组件、源码、配置与文档的入口。当前已有2375人浏览学习,可作为物流信息化项目调研、Java管理系统学习和二次开发参考的复合型资料。

1. TMS 运输管理系统:一套能跑起来的 Java 后台管理底座

我经手过一个区域配送场景,前端点了「派车」,后台三张表都写成功了,司机端却还挂着「待调度」,翻日志查了两天才定位到状态字段在两张表里各存了一份。运输管理系统真正难的从来都轮不到增删改查,难点在运单、车辆、司机、网点这几条主数据在流转过程中别互相打架。这份 TMS 项目就是照着真实运输场景搭的:下单、调度派车、提货、在途、签收、回单、运费对账一条主线走通,配套车辆档案、司机档案、线路价格、报表导出和权限体系。适合两类人看:想找一套业务完整、结构清晰的 Java 后台管理系统做二次开发交付的开发者,以及刚接手物流运输类项目、想先把骨架摸清楚的开发者。下面按我拆包的顺序讲,从技术栈到目录,再到核心链路和改造点。

2. 技术栈与工程结构:Spring Boot 多模块 + Vue 前后端分离拆解

2.1 技术选型:为什么是 Spring Boot + MyBatis-Plus 这套组合

先看数据特征再谈选型。运输管理系统的页面有八成是列表页,而且条件组合特别碎:网点在哪些范围、运单状态属于哪几个、创建时间落在哪个区间、客户名称模糊匹配、是否已结算,运气不好还要按车辆车牌反查。一个运单列表背后至少 join 车辆、司机、网点、客户、结算单五张表。这种场景下SQL 必须可控,ORM 全自动生成 SQL 的框架在这里会变成黑匣子,出了问题只能靠猜。

所以这套代码走的是 Spring Boot 2.7 + MyBatis-Plus 3.5 的路线。MyBatis-Plus 提供LambdaQueryWrapper拼常规条件,写法接近手写 SQL 但类型安全;遇到复杂的对账报表,直接把 SQL 下沉到 XML 里写,两者不冲突。这种「简单查询用 Wrapper、复杂查询回 XML」的混合用法,在物流类后台管理系统里是很常见的做法。

数据库用 MySQL 8 + InnoDB,字符集必须utf8mb4。不要小看这一条,客户名称和收货地址里出现生僻字或者表情符号的概率比你想的高,用utf8建库会在插入时直接抛异常,而且这种报错往往出现在生产环境的某一条特定数据上,本地复现不了,属于典型的玄学问题。

Redis 在这个项目里承担三件事:登录 Token 的存储与续期、字典和网点树这类低频变更数据的缓存、调度抢单时的分布式锁。第三件事是刚需,后面第 3 章会展开。定时任务用 Quartz,跑每日对账单生成和经营日报,这类跑批任务不能塞在业务请求里,否则月初对账高峰会把接口拖垮。

鉴权用 JWT 无状态方案,好处是后面要水平扩容时不用考虑 Session 共享。前端是 Vue + Element 组件库的前后端分离结构,axios统一拦截 401 跳登录。整套东西用 Maven 多模块组织,构建产物是一个可执行 jar 加一份前端静态资源。

2.2 目录结构与模块边界:谁依赖谁必须说清楚

拿到压缩包解开之后,先别急着跑,把模块依赖方向看一遍,能省掉后面很多改错地方的麻烦。典型的模块划分是这样:

tms ├── tms-admin 启动模块,只放启动类、全局异常、跨域配置 ├── tms-common 通用返回体、枚举、常量、工具类、Excel 导入导出封装 ├── tms-framework 安全框架、JWT 过滤器、AOP 数据权限、Redis 配置 ├── tms-system 用户、角色、菜单、部门、字典这些系统域 ├── tms-business 运单、调度、车辆、司机、线路、结算这些业务域 ├── tms-quartz 定时任务模块 └── tms-ui 前端工程,独立构建

依赖方向是单向的:tms-admin依赖所有模块,tms-business和tms-system依赖tms-common和tms-framework,业务模块之间尽量不互相依赖。为什么强调这个?因为我在别的项目里见过有人在tms-common里写了业务 Service,结果tms-system和tms-business互相引用,Maven 直接循环依赖报错,只能靠拆类硬解,那才叫血泪经验。

tms-common里有个容易被忽略的目录是枚举包。运单状态、车辆状态、结算状态、付款方式全部放在这里,而不是散落在各个 Service 里写字符串常量。这个约定在二次开发时价值很大——想知道系统一共有多少种运单状态,看一个文件就够了。

tms-business内部按业务域再切包,比如waybill、dispatch、vehicle、driver、settlement,每个域下固定controller / service / mapper / domain四层。这个分层看着老套,但好处是新来的人接手时不用猜代码在哪。

2.3 关键配置:启动前必须改的几处参数

配置文件集中在tms-admin/src/main/resources下,按dev、prod分环境。核心那份application.yml里,有几处是拿来即用必须改的:

server: port: 8088 servlet: context-path: /tms spring: datasource: druid: # 本地调试改成自己的库,注意时区参数,不加会导致时间差 8 小时 url: jdbc:mysql://127.0.0.1:3306/tms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 initial-size: 5 max-active: 20 validation-query: SELECT 1 redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 5s servlet: multipart: # 回单照片单张可能几 MB,默认 1MB 一定不够 max-file-size: 20MB max-request-size: 50MB tms: # 运单号前缀,一般按网点编码拼接,防止多网点号段撞车 waybill-prefix: TMS # 调度抢单锁的过期秒数,防止节点宕机后锁不释放 dispatch-lock-seconds: 30 # 金额保留小数位,运费计算统一走这个精度 amount-scale: 2

serverTimezone=Asia/Shanghai这个参数是踩坑重灾区。不写它,MySQL 驱动会按 UTC 解析,页面上所有「创建时间」都少 8 小时,测试同学提 bug 的时候你还以为是前端格式化错了。max-file-size同理,回单照片和签收单扫描件动辄几 MB,用默认值上传直接 500,日志里只看到一句SizeLimitExceededException。

dispatch-lock-seconds是我一般会单独拎出来说的参数。它决定调度抢单时 Redis 锁的存活时间,设太短,业务没跑完锁就过期,两个调度员可能同时派同一辆车;设太长,节点挂掉后锁要等很久才释放。30 秒对运单派车这种轻量操作是合适的,如果你的派车逻辑里还要调外部 GPS 接口,就得往上调。

建库和初始化数据的步骤,我通常这样走:

# 1. 建库,字符集一定用 utf8mb4 mysql -uroot -p -e "CREATE DATABASE tms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入结构和初始数据,sql 文件在 doc 目录下 mysql -uroot -p tms < doc/tms_schema.sql mysql -uroot -p tms < doc/tms_data.sql # 3. 编译并启动后端,第一次建议跳过测试,先看能不能起来 mvn clean package -DskipTests java -jar tms-admin/target/tms-admin.jar --spring.profiles.active=dev

导入 SQL 时注意执行顺序,tms_data.sql里带了菜单和角色权限的初始数据,如果只导结构不导数据,登录进去会发现左侧菜单是空的,那不是代码坏了,是权限表没数据。启动后访问http://localhost:8088/tms,前端单独用npm run dev起在 80 或 8080 端口,跨域已经在后端配好了。

3. 核心业务链路:一张运单从下单到签收的完整状态流转

3.1 运单表结构与状态机设计

运输管理系统的核心表就是运单表,其他表都是围着它转的。简化后的结构大概是这样:

CREATE TABLE tms_waybill ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', waybill_no VARCHAR(32) NOT NULL COMMENT '运单号,业务唯一', customer_id BIGINT NOT NULL COMMENT '客户ID', origin_org_id BIGINT NOT NULL COMMENT '始发网点', dest_org_id BIGINT NOT NULL COMMENT '目的网点', vehicle_id BIGINT DEFAULT NULL COMMENT '承运车辆,派车后回填', driver_id BIGINT DEFAULT NULL COMMENT '承运司机,派车后回填', status CHAR(1) NOT NULL DEFAULT '0' COMMENT '0待调度 1已派车 2已提货 3运输中 4已到达 5已签收 9已取消', freight DECIMAL(12,2) DEFAULT 0.00 COMMENT '运费', settle_status CHAR(1) NOT NULL DEFAULT '0' COMMENT '0未结算 1已生成对账单 2已收款', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_waybill_no (waybill_no), KEY idx_org_status (origin_org_id, status, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单主表';

几个字段值得单独说明。waybill_no加了唯一索引,这是防止重复下单的最后一道防线,靠代码里查一遍再插入是不保险的,并发下必然翻车。status用单字符而不是枚举类型,是因为后续加状态时改表结构成本高。version是乐观锁字段,派车和签收这种并发敏感操作要靠它兜底。复合索引idx_org_status的顺序不能乱,网点在前、状态在中、时间在后,正好贴合「某网点下某状态的运单按时间倒序」这个最高频查询。

状态流转千万别写成「前端传什么状态就存什么状态」。常见做法是定义一个状态枚举,把合法的流转关系写死在枚举内部:

public enum WaybillStatus { CREATED("0", "待调度"), DISPATCHED("1", "已派车"), PICKED("2", "已提货"), IN_TRANSIT("3", "运输中"), ARRIVED("4", "已到达"), SIGNED("5", "已签收"), CANCELED("9", "已取消"); private final String code; private final String desc; WaybillStatus(String code, String desc) { this.code = code; this.desc = desc; } /** 白名单式流转规则:key 是当前状态,value 是允许的下一批状态 */ private static final Map<WaybillStatus, Set<WaybillStatus>> RULES = new EnumMap<>(WaybillStatus.class); static { RULES.put(CREATED, EnumSet.of(DISPATCHED, CANCELED)); RULES.put(DISPATCHED, EnumSet.of(PICKED, CANCELED)); RULES.put(PICKED, EnumSet.of(IN_TRANSIT)); RULES.put(IN_TRANSIT, EnumSet.of(ARRIVED)); RULES.put(ARRIVED, EnumSet.of(SIGNED)); // 终态不给任何出口 RULES.put(SIGNED, EnumSet.noneOf(WaybillStatus.class)); RULES.put(CANCELED, EnumSet.noneOf(WaybillStatus.class)); } public boolean canTransferTo(WaybillStatus target) { return RULES.getOrDefault(this, Collections.emptySet()).contains(target); } public static WaybillStatus of(String code) { for (WaybillStatus s : values()) { if (s.code.equals(code)) { return s; } } throw new IllegalArgumentException("未知运单状态: " + code); } }

这段代码的价值在于把规则集中到了一个地方。以前我在一个项目里见过状态判断散落在十多个 Service 里,if ("3".equals(status))这种硬编码满屏都是,后来业务要加一个「已中转」状态,改了两天才改干净。用EnumMap装流转规则,好处是查表是 O(1),而且EnumSet内存占用极小。canTransferTo是唯一入口,Service 层改状态前必须先调它,不合法直接抛业务异常。终态给空集合这一句别省,签收之后还能被改成运输中,那就是典型的数据事故。

3.2 调度派车:订单池匹配与并发抢单

调度是整个系统里并发压力最集中的地方。多个调度员同时盯着待调度池,一辆车在同一秒被两个人点「指派」是常态。这里的处理链路分三步:查可用车辆、加锁、改状态。

@Service public class DispatchServiceImpl implements DispatchService { @Resource private WaybillMapper waybillMapper; @Resource private VehicleMapper vehicleMapper; @Resource private StringRedisTemplate redisTemplate; @Value("${tms.dispatch-lock-seconds:30}") private long lockSeconds; @Override @Transactional(rollbackFor = Exception.class) public void dispatch(Long waybillId, Long vehicleId, Long driverId) { String lockKey = "tms:dispatch:waybill:" + waybillId; // setIfAbsent 等价于 SET NX,抢不到锁说明别人正在处理同一张单 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(lockSeconds)); if (Boolean.FALSE.equals(locked)) { throw new ServiceException("该运单正在被其他调度员处理,请稍后刷新"); } try { Waybill waybill = waybillMapper.selectById(waybillId); if (waybill == null) { throw new ServiceException("运单不存在"); } // 第一道校验:状态机 if (!WaybillStatus.of(waybill.getStatus()).canTransferTo(WaybillStatus.DISPATCHED)) { throw new ServiceException("当前运单状态不允许派车"); } Vehicle vehicle = vehicleMapper.selectById(vehicleId); if (vehicle == null || !"1".equals(vehicle.getStatus())) { throw new ServiceException("车辆不可用,可能已被占用或处于维修中"); } // 第二道校验:带版本的更新,并发下只会有一条成功 int rows = waybillMapper.dispatch(waybillId, vehicleId, driverId, WaybillStatus.DISPATCHED.getCode(), waybill.getVersion()); if (rows == 0) { throw new ServiceException("运单已被更新,请刷新后重试"); } // 车辆置为占用,同样带状态条件,避免重复指派 vehicleMapper.occupy(vehicleId); } finally { // 释放锁,放在 finally 里,业务异常也要放 redisTemplate.delete(lockKey); } } }

对应的 Mapper 方法要写成条件更新,这是关键:

@Update("UPDATE tms_waybill SET status = #{status}, vehicle_id = #{vehicleId}, " + "driver_id = #{driverId}, version = version + 1 " + "WHERE id = #{id} AND version = #{version} AND status = '0'") int dispatch(@Param("id") Long id, @Param("vehicleId") Long vehicleId, @Param("driverId") Long driverId, @Param("status") String status, @Param("version") Integer version);

逻辑上这是三层防护。Redis 锁挡住同一张单的并发请求,属于第一层,特点是粗粒度但响应快。状态机校验挡住「已经派过车的单再派一次」这种业务错误,是第二层。WHERE version = #{version} AND status = '0'是第三层,也是最硬的一层——即便 Redis 挂了、锁失效了,数据库层面也只可能有一条 UPDATE 影响行数为 1,其余全部返回 0 被拦下。参数上,version用的是查询出来的旧值,更新成功自动 +1,这是标准的乐观锁写法。

我不是很喜欢只用 Redis 锁的做法。锁的本质是减少冲突,但真正的正确性必须落在数据库的条件更新上,不然 Redis 一抖动就出事。这套组合的代价是每次派车多一次 Redis 往返,对几百 QPS 的量级完全无感。

派车成功后还有一步常被漏掉:写入调度流水。谁在什么时间把哪张单派给了哪辆车,这个记录在做异常追溯时是唯一凭据。常见做法是同一事务里插一条tms_dispatch_log,表结构简单,但排障时救命。

3.3 运费计算与对账单生成

运费计算是运输管理系统里业务差异最大的地方。有的客户按重量算,有的按体积,有的按「首重 + 续重」,还有的按线路一口价。如果写成一堆 if-else 塞在 Service 里,后面每接一个新客户就要改一次核心代码,改动风险极高。

合理的做法是策略模式,把计算方式抽象成接口:

public interface FreightCalculator { /** 计费类型标识,与客户档案上配置的计费方式对应 */ String type(); /** 根据上下文计算运费 */ BigDecimal calc(FreightContext ctx); } @Component public class WeightFreightCalculator implements FreightCalculator { @Override public String type() { return "WEIGHT"; } @Override public BigDecimal calc(FreightContext ctx) { // 计费重量取实际重量和体积重量中的较大值,这是行业惯例 BigDecimal volumeWeight = ctx.getVolume() .multiply(ctx.getVolumeRatio() == null ? new BigDecimal("200") : ctx.getVolumeRatio()); BigDecimal chargeWeight = ctx.getWeight().max(volumeWeight); BigDecimal unitPrice = ctx.getUnitPrice() == null ? BigDecimal.ZERO : ctx.getUnitPrice(); // 统一按配置精度四舍五入,避免各计算器精度不一致 return chargeWeight.multiply(unitPrice) .setScale(ctx.getScale(), RoundingMode.HALF_UP); } }

FreightContext是个纯数据载体,装重量、体积、件数、始发目的网点、客户 ID、单价、体积重换算系数、精度这些字段。把它做成独立对象而不是一长串方法参数,好处是新增计费维度时不用改所有实现的签名。volumeRatio默认 200 表示 1 立方米折算 200 千克,这个系数各家公司不一样,所以做成可配置。

策略实现类用@Component注册后,需要一个工厂来路由:

@Component public class FreightCalculatorFactory { private final Map<String, FreightCalculator> registry; // 构造器注入所有实现,Spring 启动时一次性建好映射表 public FreightCalculatorFactory(List<FreightCalculator> calculators) { this.registry = calculators.stream() .collect(Collectors.toMap(FreightCalculator::type, Function.identity())); } public FreightCalculator get(String type) { FreightCalculator calculator = registry.get(type); if (calculator == null) { throw new ServiceException("未配置的计费方式: " + type); } return calculator; } }

用构造器注入List<T>拿全部实现,是 Spring 里很顺手的一个特性,不用手动维护注册表,新增一个实现类自动就能被识别。Collectors.toMap这里要留意,如果两个实现类写了同一个type()值,启动时会直接抛IllegalStateException,这反而是好事,能在启动阶段就暴露配置冲突,比运行到一半才发现两条规则打架强。

对账单生成走定时任务,一般是每天凌晨跑 T+1 的运单,按客户 + 结算周期聚合。核心逻辑是先把符合条件的运单查出来,按客户分组求和,生成对账单主表和明细表,同时把运单的settle_status从 0 改成 1。这里必须加幂等判断:同一客户同一周期如果已存在对账单,直接跳过,否则任务重跑一次就会生成两份,财务那边会直接来找你。

4. 权限体系:RBAC 菜单权限与网点数据隔离怎么落

4.1 登录鉴权与 Token 刷新链路

登录流程本身的实现很直接:账号密码校验通过后签发 JWT,同时把用户信息写进 Redis,key 是login:token:{uuid},过期时间和 Token 一致。为什么 Token 里已经有了用户 ID 还要存 Redis?因为要支持「强制下线」和「权限变更即时生效」。纯无状态 JWT 在用户被停用后依然能访问接口直到过期,这在后台管理系统里是不能接受的。

@Override public String login(String username, String password) { LoginUser loginUser = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(username, password)).getPrincipal(); // 生成 uuid 作为 redis key,jwt 里只放 uuid,不暴露用户信息 String token = IdUtil.fastSimpleUUID(); loginUser.setToken(token); recordLoginInfo(loginUser.getUserId()); // 刷新用户缓存:角色、权限、所属网点都放进去,避免每次请求查库 redisCache.setCacheObject(LOGIN_TOKEN_KEY + token, loginUser, expireTime, TimeUnit.MINUTES); return jwtService.createToken(token); }

把权限集合放进缓存而不是塞进 JWT,还有个实际好处:JWT 体积小,HTTP 头不会过长;权限变更时只要删掉 Redis 里的 key,用户下次请求就重新加载,不用等 Token 过期。

请求进来后,JWT 过滤器解析出 uuid,去 Redis 取LoginUser,取不到就直接返回 401。这里有个细节:每次命中后要不要续期?一般做法是剩余时间小于某个阈值(比如 20 分钟)时自动续一次,否则用户正在填一张复杂的运单,填到一半被踢出登录,体验很差。

4.2 菜单、按钮、接口三级权限

后台管理系统常见的权限分三层,缺一层都是漏洞。

第一层是菜单权限,决定左侧导航显示什么。用户表关联角色,角色关联菜单,菜单表里有menu_type字段区分目录、菜单、按钮。登录后按用户查菜单树,前端渲染路由。

第二层是按钮权限,决定页面上「新增」「导出」「派车」这些按钮是否显示。实现上一般是把按钮也当成菜单记录,perms字段写tms:waybill:add这样的标识,前端用自定义指令判断。

第三层是接口权限,也是唯一真正能挡住人的一层。前两层都是体验优化,接口不拦的话,随便用 Postman 发个请求就能改数据。

@PreAuthorize("@ss.hasPermi('tms:waybill:dispatch')") @PostMapping("/dispatch") public AjaxResult dispatch(@RequestBody DispatchBody body) { // 权限注解在方法进入前就完成校验,不通过直接抛 403 waybillService.dispatch(body.getWaybillId(), body.getVehicleId(), body.getDriverId()); return AjaxResult.success(); }

@ss.hasPermi是自定义的 SpEL Bean,内部从LoginUser的权限集合里判断是否包含该标识。用注解的好处是权限点和代码放在一起,一眼能看出这个接口需要什么权限;坏处是容易漏加,所以项目里一般还会配一个扫描工具,启动时把没有权限注解的 Controller 方法打日志列出来。

4.3 网点数据权限:让 A 网点的人看不到 B 网点的单

这是运输管理系统区别于普通后台管理系统的地方。同一个「运单列表」接口,总部管理员要看全部,区域经理要看下属几个网点,网点操作员只能看自己网点。如果每个 Service 里都手写一段if (role == ...) { wrapper.eq("origin_org_id", orgId); },代码会迅速失控。

常见做法是用 AOP + 注解,在 SQL 执行前自动拼条件:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** 网点字段名,运单表是 origin_org_id,车辆表是 org_id */ String orgAlias() default "org_id"; } @Aspect @Component public class DataScopeAspect { @Before("@annotation(dataScope)") public void doBefore(JoinPoint point, DataScope dataScope) { LoginUser user = SecurityUtils.getLoginUser(); // 超级管理员不拼条件,直接放行 if (user.isAdmin()) { return; } StringBuilder sql = new StringBuilder(); DataScopeType type = DataScopeType.of(user.getDataScope()); switch (type) { case ALL: break; case DEPT: sql.append(" AND ").append(dataScope.orgAlias()) .append(" IN (SELECT org_id FROM sys_org WHERE org_id = ") .append(user.getOrgId()).append(")"); break; case DEPT_AND_CHILD: // find_in_set 沿祖级路径匹配,性能可接受因为网点数量有限 sql.append(" AND ").append(dataScope.orgAlias()) .append(" IN (SELECT org_id FROM sys_org WHERE org_id = ") .append(user.getOrgId()).append(" OR find_in_set(") .append(user.getOrgId()).append(", ancestors))"); break; default: // 兜底:只查自己 sql.append(" AND ").append(dataScope.orgAlias()) .append(" = ").append(user.getOrgId()); } // 把拼好的条件塞进 MyBatis-Plus 的参数里,由 XML 中的 ${params.dataScope} 取用 point.getArgs()[0] = withDataScope(point.getArgs()[0], sql.toString()); } }

这段代码里最关键的不是 switch,而是最后的兜底分支。所有权限判断的default都必须是「拒绝」而不是「放行」,否则以后新增一种数据范围枚举值,忘了加分支,结果是所有人都能看到全部数据,这种漏洞往往几个月都没人发现。

ancestors是网点表里的祖级路径字段,存成0,1,5这种形式,配合find_in_set可以做树形递归查询,比程序里递归查库效率高得多。网点数量一般在一千以内,这个开销可以接受。如果网点规模上万,就得换成闭包表或者缓存整棵树在内存里比对。

还有一个容易踩的点:带@DataScope的方法,第一个参数必须是要注入条件的查询对象,而且 XML 里对应的 SQL 结尾必须留${params.dataScope}的插槽。新增一个查询方法忘了加插槽,权限就静默失效了,接口能跑,数据也返回,只是多了不该看的。我在项目里一般会在代码评审清单里固定写上这一条。

5. 部署上线与高频问题排查:本地能跑、服务器报错的 5 个坑

5.1 环境准备与启动顺序

整套系统的启动顺序有讲究,顺序错了会看到一堆看起来像代码 bug 的报错。

# 1. 后端打包,注意跳过测试,很多老项目单测依赖外部环境 mvn clean package -DskipTests -Pprod # 2. 前端构建,产物在 dist 目录,交给 nginx 托管 npm install --registry=https://registry.npmmirror.com npm run build:prod # 3. 起后端,prod 环境读 application-prod.yml nohup java -Xms512m -Xmx1024m -jar tms-admin/target/tms-admin.jar \ --spring.profiles.active=prod > logs/start.log 2>&1 & # 4. 看启动结果,重点确认 Tomcat 端口和 Redis 连接两行日志 tail -f logs/start.log

顺序上的硬性要求是:MySQL 和 Redis 必须先可用,再启动后端。后端启动时会做连接池预热和字典缓存加载,数据库没起来会直接启动失败。前端可以任何时候构建,因为它只产出静态文件,真正对接后端是在 nginx 里配反向代理那一步。

-Xmx1024m这个值不是随便写的。这个体量的后台管理系统,默认堆内存往往不够跑报表导出,尤其是导出几万行运单到 Excel 的时候,OutOfMemoryError会准时出现。如果导出量大,还得改用流式写出,比如 EasyExcel 的SXSSF模式,边查边写,不把全量数据塞内存。

nginx 那边的配置里有两个容易漏的点:proxy_read_timeout要调大,否则导出大文件时网关先超时;client_max_body_size要调大,和前面 Spring 的max-file-size对齐,不然回单照片会在网关层就被拒掉。

5.2 五个高频问题:现象、原因、解决

问题一:登录成功后刷新页面就退回登录页。

现象是登录接口返回 200,Token 也存进 localStorage 了,但刷新后立刻跳回登录页。原因通常是前端路由守卫拿 Token 去调/getInfo接口,返回 401,而后端 401 是因为请求头里没带上 Authorization。查下来是 axios 拦截器注册顺序写反了,请求拦截器在实例创建之后才挂载,导致部分请求裸奔。解决办法是把拦截器配置和 axios 实例的创建放在同一个模块里,并且在路由守卫里加一层判断:Token 存在但getInfo失败时,先清 Token 再跳登录,避免死循环跳转。

问题二:列表页数据正常,导出 Excel 报 500,日志里只有一句空指针。

现象很典型:页面上有数据,点导出就报错,堆栈指到某个getXxx()上。原因是对账或导出用的 VO 里加了@Excel注解的字段是包装类型,而查询 SQL 里没有 select 这个字段,取出来是 null,导出工具做类型转换时炸了。解决分两步:一是在 XML 的查询字段里补齐导出需要的列;二是给导出工具的字段转换加上 null 兜底,比如配置默认值。这属于那种不写清楚会反复踩的坑。

问题三:定时对账任务每天生成两份对账单。

现象是财务发现同一客户同一天有两张单。原因是任务在集群里部署了两个实例,Quartz 用的是内存存储,两个节点各跑一遍。解决是改成数据库存储的 Quartz 集群模式,加QRTZ_LOCKS行锁;同时业务侧补上幂等判断——生成前先按「客户 + 结算周期」查一次是否已存在,存在就跳过。两层都做才稳,只做幂等虽然结果对了,但日志里每天都会刷一遍异常,排查别的问题时很干扰。

问题四:派车接口偶发「运单已被更新,请刷新后重试」。

现象是调度员反馈偶尔派车失败,频率不高。这是乐观锁在正常工作,不是 bug,但要区分两种情况:如果确实有别人同时操作,提示重试是合理的;如果只有一个人操作也频繁报,那多半是前端页面停留太久,拿的是几分钟前的version。解决是在前端提交前先静默拉一次最新数据比对状态,或者在提示语里写清楚「数据已变更,正在为你刷新」,自动重新加载而不是让用户干瞪眼。

问题五:服务器上时间显示正常,但接口返回的时间比实际少 8 小时。

现象是数据库里create_time是对的,接口 JSON 里少了 8 小时。原因是 JDBC URL 少了serverTimezone参数,或者spring.jackson.time-zone没配。解决是两头都配:连接串里明确serverTimezone=Asia/Shanghai,Jackson 配置里也指定时区,@JsonFormat注解的timezone属性同样要写。这三处只要有一处漏了,就会出现「数据库对、接口错、页面又对」的诡异现象,因为每一层都可能自行转换。

6. 二次开发:给现有 TMS 接一套自定义计费规则

接新客户时最常见的要求是「我们公司运费算法跟别人不一样」。有了前面那套策略结构,加一种计费方式其实就三步,不用动任何已有代码。

第一步,写实现类,声明自己的type。比如客户要求「首重 30 公斤内固定价,超出部分每公斤单价折算」:

@Component public class FirstWeightCalculator implements FreightCalculator { @Override public String type() { return "FIRST_WEIGHT"; } @Override public BigDecimal calc(FreightContext ctx) { BigDecimal firstWeight = new BigDecimal("30"); BigDecimal basePrice = ctx.getBasePrice(); // 首重价格,来自线路报价 if (ctx.getWeight().compareTo(firstWeight) <= 0) { return basePrice.setScale(ctx.getScale(), RoundingMode.HALF_UP); } BigDecimal extra = ctx.getWeight().subtract(firstWeight) .multiply(ctx.getUnitPrice()); return basePrice.add(extra).setScale(ctx.getScale(), RoundingMode.HALF_UP); } }

第二步,在客户档案里把计费方式配成FIRST_WEIGHT,线路报价里维护首重价格和续重单价。第三步,重启后调用一次计算接口验证,FreightCalculatorFactory的构造器注入会自动把它注册进去,不需要改工厂类。这里有一个验证技巧:先在测试环境把同一个FreightContext分别喂给新旧两个计算器,对比结果差异,确认只在新客户的数据上生效,再切生产。

灰度上我一般会加一个开关配置,按客户 ID 白名单放量,而不是一次性全量。开关放在tms.freight.strategy-switch下面,配置中心能实时改,出问题时改回原值就行,这就是给自己留后悔药。

从那以后我每次给计费模块加策略,都会先把FreightContext的字段清单打印出来过一遍,确认没有哪个维度是我以为有、实际为 null 的——因为空值参与BigDecimal运算不会报错,只会算出一个看起来合理但其实错的金额,这种错误比对账时才发现,返工成本最高。希望这套拆解能帮到你,少走几段弯路。

本文还有配套的精品资源,点击获取