简介:这份资源是面向JavaWeb初学者与课程设计者的物流管理系统完整项目包,对应系列教程第43部分,可用于毕业设计、课程实训或自学练手。系统围绕物流业务流程展开,涵盖订单管理、仓储信息、配送跟踪、用户注册与收藏记录等模块,采用Servlet、JSP、JDBC结合SSM框架实现,是理解MVC分层与数据库交互的典型实例。压缩包共717个文件,约30.67MB,包含97个java源码、82个jsp页面、97个class编译文件、77个jar依赖、51个xml配置及大量png、js、css等前端资源,另附设计文档与实操视频,便于对照需求分析、架构设计与代码实现。目前已有115人学习下载。读者可借助源码、文档与录屏,掌握HTTP请求响应处理、数据库操作与项目部署排错思路,适合希望提升JavaWeb开发技能或对物流管理有兴趣的学习者。
1. 物流管理系统用 Javaweb 落地:从运单录入到库存扣减的完整链路
很多做 Javaweb 项目的人,第一次接触物流管理系统时,脑子里想的都是「增删改查四个页面搞定」。真动手才发现,运单状态流转、库存并发扣减、多角色权限这三件事,随便拎一个出来都能让系统在演示时当场翻车。物流管理系统的核心不是页面多,而是业务状态机要闭环:一张运单从下单、揽收、运输、派送到签收,每一步都要有对应的库存动作和权限校验,漏一环数据就对不上。
这个方向适合两类人:一是课程设计或毕设需要完整案例的在校开发者,二是想用 SpringBoot + MySQL 练手真实业务的后端新人。它不需要分布式、不需要微服务,一台机器跑通 Tomcat 加 MySQL 就能演示全流程。但正因为看起来简单,很多人栽在事务边界和并发控制上。下面按「先立住模型、再跑通链路、最后堵住坑」的顺序,把一套能复现的方案讲清楚。
2. 物流管理系统的数据模型与状态机设计:先画清楚再写代码
2.1 五张核心表撑起运单全生命周期
物流系统的表设计最忌讳一上来就堆字段。我一般先问三个问题:谁创建运单、谁改变运单状态、谁需要查运单。答案对应三类角色——客户、操作员、管理员,表结构围绕这三个角色的动作展开。
核心表就五张:waybill(运单主表)、waybill_trace(轨迹表)、warehouse_stock(库存表)、sys_user(用户表)、sys_role(角色表)。运单主表存当前状态和收发货人信息,轨迹表存每一次状态变更的历史记录,库存表按仓库和商品维度记录可用数量。
-- 运单主表:状态字段用枚举值,不用中文 CREATE TABLE waybill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL UNIQUE COMMENT '运单号,业务唯一键', sender_name VARCHAR(64) NOT NULL, receiver_name VARCHAR(64) NOT NULL, goods_id BIGINT NOT NULL COMMENT '关联商品', quantity INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待揽收 1运输中 2派送中 3已签收 4已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_status (status), INDEX idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 轨迹表:每次状态变更插一条,不做更新 CREATE TABLE waybill_trace ( id BIGINT PRIMARY KEY AUTO_INCREMENT, waybill_no VARCHAR(32) NOT NULL, from_status TINYINT, to_status TINYINT NOT NULL, operator_id BIGINT NOT NULL, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_waybill_no (waybill_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;状态字段用TINYINT而不是VARCHAR,原因是状态流转判断在代码里做数值比较比字符串比较快,而且数据库层面可以用CHECK约束防止写入非法值。轨迹表只插入不更新,这样任何时刻都能回溯一张运单的完整路径,排查问题时不用猜。
注意:
waybill_no必须加唯一索引。我见过有人用时间戳生成运单号,高并发下重复了才发现没加约束,后悔药都没得吃。
2.2 状态流转用枚举加校验,别让非法跳转进数据库
运单状态不是随便改的。待揽收只能变运输中或已取消,运输中只能变派送中,派送中只能变已签收。如果代码里不校验,操作员误点一下就能把「已签收」改回「运输中」,库存和轨迹全乱。
常见做法是定义一个状态枚举类,把允许的流转关系写死在Map里,每次更新前先查当前状态再判断目标状态是否合法。
public enum WaybillStatus { PENDING(0, "待揽收"), IN_TRANSIT(1, "运输中"), DELIVERING(2, "派送中"), SIGNED(3, "已签收"), CANCELLED(4, "已取消"); private final int code; private final String desc; // 允许的流转关系:key 是当前状态,value 是可达状态集合 private static final Map<Integer, Set<Integer>> TRANSITIONS = Map.of( 0, Set.of(1, 4), 1, Set.of(2), 2, Set.of(3), 3, Set.of(), 4, Set.of() ); public static boolean canTransfer(int from, int to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }TRANSITIONS用Map.of初始化,不可变,避免运行期被误改。canTransfer在 Service 层更新状态前调用,返回false直接抛业务异常。这样即使前端传了非法状态值,后端也能拦住。
参数说明:from是数据库里查出来的当前状态码,to是请求要改成的目标状态码。两个都用int而不是枚举对象,是为了和数据库TINYINT直接对应,减少转换层。
2.3 库存扣减放在状态变更的同一个事务里
物流系统里库存扣减最容易出问题。运单从「待揽收」变「运输中」时,商品才算真正出库,这时候扣库存。如果扣库存和改状态不在一个事务里,状态改了库存没扣,或者库存扣了状态没改,数据就对不上。
@Service public class WaybillService { @Autowired private WaybillMapper waybillMapper; @Autowired private StockMapper stockMapper; @Autowired private TraceMapper traceMapper; @Transactional(rollbackFor = Exception.class) public void transferStatus(String waybillNo, int targetStatus, Long operatorId) { Waybill waybill = waybillMapper.selectByNo(waybillNo); if (waybill == null) { throw new BizException("运单不存在"); } int currentStatus = waybill.getStatus(); if (!WaybillStatus.canTransfer(currentStatus, targetStatus)) { throw new BizException("非法状态流转"); } // 出库时扣库存,用乐观锁防止超卖 if (currentStatus == 0 && targetStatus == 1) { int affected = stockMapper.deductStock(waybill.getGoodsId(), waybill.getQuantity()); if (affected == 0) { throw new BizException("库存不足"); } } waybillMapper.updateStatus(waybillNo, targetStatus); traceMapper.insert(waybillNo, currentStatus, targetStatus, operatorId); } }@Transactional的rollbackFor指定Exception.class,因为默认只回滚运行时异常,业务异常如果继承的是Exception而不是RuntimeException,不指定就不会回滚。deductStock的 SQL 里带AND stock >= #{quantity}条件,返回影响行数为 0 说明库存不够,直接抛异常触发回滚。
提示:库存扣减的 SQL 一定要写成
UPDATE warehouse_stock SET stock = stock - #{quantity} WHERE goods_id = #{goodsId} AND stock >= #{quantity},把判断和扣减合并成一条原子操作,不要先查再扣。
3. 用 SpringBoot 跑通运单录入到签收的最小链路
3.1 项目骨架和依赖版本怎么选
Javaweb 项目现在主流用 SpringBoot 2.7.x 配 JDK 8 或 11,MySQL 用 5.7 或 8.0 都行。MyBatis-Plus 比原生 MyBatis 省掉大量 XML,适合这种表不多的系统。前端不用搞太复杂,Thymeleaf 或者纯静态 HTML 加 AJAX 都能演示。
pom.xml核心依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> </dependencies>版本不要追新。SpringBoot 3.x 要求 JDK 17,很多学校机房还是 JDK 8,跑不起来。MyBatis-Plus 3.5.x 和 SpringBoot 2.7.x 是经过大量项目验证的组合,踩坑概率最低。
application.yml里数据库连接加rewriteBatchedStatements=true,批量插入轨迹时性能差别明显。连接池用默认的 HikariCP 就够,不用换 Druid。
3.2 运单录入接口:参数校验和运单号生成
运单录入是第一个要跑通的接口。请求进来先校验必填字段,再生成运单号,最后落库。运单号格式我一般用「日期 + 6 位随机数」,比如20250115-384729,可读性好,排查问题时一眼能看出日期。
@RestController @RequestMapping("/api/waybill") public class WaybillController { @Autowired private WaybillService waybillService; @PostMapping("/create") public Result create(@RequestBody @Valid WaybillCreateDTO dto) { String waybillNo = waybillService.createWaybill(dto); return Result.ok(waybillNo); } } // DTO 上用注解做基础校验 public class WaybillCreateDTO { @NotBlank(message = "发件人不能为空") private String senderName; @NotBlank(message = "收件人不能为空") private String receiverName; @NotNull(message = "商品不能为空") private Long goodsId; @Min(value = 1, message = "数量至少为1") private Integer quantity; }@Valid触发校验,校验失败 SpringBoot 会抛MethodArgumentNotValidException,配一个全局异常处理器统一返回错误信息。运单号生成放在 Service 里,用LocalDate.now()加ThreadLocalRandom生成,不用Math.random(),避免多线程下重复。
参数说明:goodsId和quantity是库存扣减的依据,创建运单时不扣库存,只记录。真正扣库存在状态流转到「运输中」时执行。
3.3 轨迹查询和分页:用 MyBatis-Plus 省掉手写 SQL
轨迹查询按运单号查,按时间倒序。MyBatis-Plus 的LambdaQueryWrapper写起来比 XML 快,而且字段名用方法引用,改字段时编译期就能发现。
@Service public class TraceService { @Autowired private WaybillTraceMapper traceMapper; public Page<WaybillTrace> queryByWaybillNo(String waybillNo, int page, int size) { LambdaQueryWrapper<WaybillTrace> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(WaybillTrace::getWaybillNo, waybillNo) .orderByDesc(WaybillTrace::getCreateTime); return traceMapper.selectPage(new Page<>(page, size), wrapper); } }分页参数page从 1 开始,size默认 10。MyBatis-Plus 的分页插件需要在配置类里注册MybatisPlusInterceptor并添加PaginationInnerInterceptor,不注册分页不生效,查出来永远是全量。这个坑我踩过,日志里 SQL 没有LIMIT才反应过来。
注意:轨迹表数据量会随时间增长,如果单运单轨迹超过几百条,考虑按
create_time做冷热分离,或者加waybill_no + create_time的联合索引。单列索引在数据量大时回表次数多,查询会变慢。
4. 权限控制和并发扣减的避坑排查
4.1 角色权限别用硬编码,用注解加拦截器
物流系统里客户只能看自己的运单,操作员能改状态,管理员能看所有。硬编码if (role == "admin")写多了没法维护。常见做法是自定义一个@RequireRole注解,配一个拦截器在方法执行前校验。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); } // 拦截器里取当前登录用户角色,判断是否在注解允许的范围内 public class RoleInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) return true; HandlerMethod method = (HandlerMethod) handler; RequireRole annotation = method.getMethodAnnotation(RequireRole.class); if (annotation == null) return true; String userRole = (String) request.getSession().getAttribute("role"); for (String allowed : annotation.value()) { if (allowed.equals(userRole)) return true; } throw new BizException("无权限操作"); } }拦截器注册在WebMvcConfigurer的addInterceptors里,addPathPatterns("/api/**")拦截所有接口。用户角色存在 Session 里,登录时写入。这样新增接口只要加注解就行,不用改拦截器逻辑。
4.2 并发扣减的三种翻车场景和排查方法
库存扣减在演示时通常没问题,一上并发就出幺蛾子。下面三种情况我实际遇到过。
现象一:库存扣成负数。原因是用「先查后扣」的写法,两个线程同时查到库存为 1,都判断够扣,然后各自执行UPDATE stock = 1 - 1,最终库存变成 -1。解决方法是把判断条件写进UPDATE的WHERE里,利用数据库行锁保证原子性。排查时看warehouse_stock表有没有负值,有就是这个问题。
现象二:状态改了但库存没扣。原因是扣库存和改状态不在同一个事务,或者事务方法被同类内部调用导致代理失效。Spring 的@Transactional基于 AOP 代理,同一个类里方法 A 调方法 B,B 上的事务注解不生效。解决方法是把扣库存逻辑抽到另一个 Service 里,或者用AopContext.currentProxy()拿代理对象调用。排查时看日志里两个操作是否在同一个SqlSession里。
现象三:轨迹表插入顺序和状态变更顺序不一致。原因是轨迹插入用了异步线程,主线程状态已经改了,异步线程还没插完,查出来的轨迹顺序是乱的。解决方法是在同一个事务里同步插入轨迹,不要用@Async。排查时对比waybill.update_time和waybill_trace.create_time,如果轨迹时间早于状态更新时间,就是异步导致的。
提示:并发测试不要用 Postman 手动点,用 JMeter 或
ab命令开 50 个线程同时打同一个接口,跑完查库存和轨迹条数,对不上就是有并发问题。
4.3 事务失效的四个常见原因
除了同类内部调用,还有三种情况会让@Transactional失效。一是方法不是public的,Spring 代理不拦截私有方法。二是异常被try-catch吞了,没抛出去,事务管理器认为没出错就不回滚。三是数据库引擎是 MyISAM,不支持事务,建表时必须用 InnoDB。四是rollbackFor没配,业务异常继承自Exception而不是RuntimeException,默认不回滚。
排查事务问题最直接的方法是打开 Spring 的事务日志,在application.yml里加logging.level.org.springframework.transaction=DEBUG,每次事务开启、提交、回滚都会打日志。看到Creating new transaction说明事务生效了,没看到就是没生效。
5. 用状态机加乐观锁把签收环节做扎实
签收是运单的最后一步,也是最容易出问题的一步。客户点「确认签收」,系统要同时做三件事:把状态改成已签收、插入签收轨迹、如果涉及退货还要恢复库存。这三件事必须原子完成。
我一般会在签收接口上加一层乐观锁,用version字段防止重复签收。运单表加一个version INT DEFAULT 0,每次更新时UPDATE waybill SET status = 3, version = version + 1 WHERE waybill_no = ? AND version = ?。如果影响行数为 0,说明运单在本次操作前已经被别人改过了,直接返回「运单状态已变更,请刷新后重试」。
public void signWaybill(String waybillNo, Long operatorId, int expectedVersion) { Waybill waybill = waybillMapper.selectByNo(waybillNo); if (waybill.getStatus() != 2) { throw new BizException("只有派送中的运单才能签收"); } int affected = waybillMapper.signWithVersion(waybillNo, expectedVersion); if (affected == 0) { throw new BizException("运单状态已变更,请刷新后重试"); } traceMapper.insert(waybillNo, 2, 3, operatorId); }expectedVersion从前端查询详情时一起返回,提交签收时带回来。这样即使用户开了两个标签页同时点签收,也只有一个能成功。另一个会收到提示,不会产生两条签收轨迹。
验证方法很简单:用两个浏览器窗口打开同一张运单的签收页面,同时点确认。正常情况下一个成功一个提示重试,数据库里waybill_trace只有一条to_status = 3的记录。如果出现两条,说明乐观锁没生效,检查version字段有没有在查询时带出来、更新时有没有作为条件。
这套方案我在几个课程设计项目里反复用过,最深的教训是:不要等到演示前一天才测并发。平时写完一个状态流转接口,就顺手用ab -n 100 -c 20压一下,看看库存和轨迹对不对。物流系统的数据一致性比页面好看重要得多,状态机画清楚、事务边界划明白、并发控制加上去,剩下的就是体力活。希望帮到你。
本文还有配套的精品资源,点击获取