JavaEE SSH 供应链管理系统实战:采购入库库存事务与避坑指南

发布时间:2026/10/30 14:24:10
JavaEE SSH 供应链管理系统实战:采购入库库存事务与避坑指南

简介:这份资源是面向计算机相关专业毕业设计场景的百货中心供应链管理系统完整项目,采用JavaEE技术栈,结合SSH框架与MySQL数据库实现,适合正在准备毕设或课程设计的学生参考与二次开发。压缩包共179个文件,约117.58MB,涵盖50个Java源文件、26个JSP页面、21个JavaScript脚本、19个XML配置以及SQL建库脚本、properties配置、PPT与MP4辅导视频等,源码、文档与讲解视频配套齐全,便于理解系统整体架构与业务逻辑。项目围绕采购、销售、库存、合作等供应链核心模块展开,包含Action、Service、DAO分层实现,结构清晰,适合学习SSH整合开发与数据库设计。目前已有553人学习下载,可作为毕设选题的完整参考方案,帮助读者快速掌握项目搭建、模块划分与调试排错思路。

1. 百货中心供应链管理系统:从一张采购单到一套能跑起来的 JavaEE 工程

百货中心的供应链和便利店完全不是一个量级。一个中型百货中心,SKU 动辄几万条,供应商几百家,采购、入库、调拨、退货、结算五条线并行跑,任何一条线靠 Excel 和微信群撑着,月底对账一定翻车。这个标题讲的,就是用 JavaEE + SSH(Struts2 + Spring + Hibernate)+ MySQL 搭一套能管住这些流程的供应链管理系统,并且带源码和文档。它解决的核心问题很具体:采购单从谁发起、经过谁审批、入库后库存怎么变、供应商结算数据从哪来。适合两类人——一是课程设计或毕设阶段需要一套完整 JavaWeb 项目练手的同学,二是中小型零售企业里想用成熟技术栈自建进销存后台的开发者。SSH 这套组合今天看不算新,但它的分层结构清晰、资料多、排错路径成熟,作为供应链这类业务逻辑偏重的系统,反而比追新框架更稳。下面我按实际落地的顺序,把选型、建表、核心模块、避坑和验证一条条拆开讲。

2. SSH 三层怎么分工:为什么供应链系统适合这套老组合

2.1 Struts2 管请求、Spring 管对象、Hibernate 管持久化

SSH 不是三个框架简单叠加,而是各管一段。Struts2 站在最前面接 HTTP 请求,把表单参数封装成 Action 的属性,再决定跳哪个页面;Spring 在中间做 IoC 容器和事务管理,Action、Service、DAO 这些对象都由它创建和注入,事务边界也压在 Service 层;Hibernate 在最底下把 Java 对象和 MySQL 表做映射,开发者写的是session.save(purchaseOrder)而不是拼 SQL。

供应链系统的业务特点决定了这个分工很合适。采购单审批、库存扣减、结算生成,这些操作往往要跨多张表,必须放在一个事务里。Spring 的声明式事务只要在 Service 方法上加@Transactional,Hibernate 的 Session 就会跟着事务走,中间任何一步抛异常整体回滚。如果用裸 JDBC,这些回滚逻辑得手写,出错概率高得多。

分层之后,改需求的影响面也可控。比如采购单要加一个「紧急程度」字段,改的是实体类、映射文件和 JSP,Service 和 Action 基本不动。这就是为什么很多企业后台至今还在用 SSH——不是技术先进,是维护成本低。

2.2 环境搭建:JDK、Tomcat、MySQL 的版本对齐

动手前先把版本定死,SSH 对版本比较敏感。我一般用这套组合:JDK 8、Tomcat 8.5、MySQL 5.7 或 8.0、Struts2 2.5.x、Spring 4.3.x、Hibernate 5.2.x。JDK 别上 11 以上,Struts2 老版本在模块化上有坑。

MySQL 安装时字符集一定要选utf8mb4,供应链系统里商品名、供应商名经常有生僻字和特殊符号,用utf8存进去会变问号。安装完先建库:

CREATE DATABASE supply_chain DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER 'sc_user'@'localhost' IDENTIFIED BY 'Sc@2024'; GRANT ALL PRIVILEGES ON supply_chain.* TO 'sc_user'@'localhost'; FLUSH PRIVILEGES;

建库时显式指定utf8mb4,是因为 MySQL 8.0 默认虽然是 utf8mb4,但 5.7 默认是 latin1,不指定后面中文全乱。单独建用户而不是直接用 root,是为了后面配连接池时权限清晰,也方便排查「认证失败」类问题——如果连不上,先确认用户主机是localhost还是%,本地开发用localhost就够。

Tomcat 这边,把项目打成 war 丢进 webapps 是最省事的部署方式。启动前检查conf/server.xml里的端口有没有被占用,8005、8080、8009 三个端口冲突是新手最常见的启动失败原因。

2.3 用 Maven 把依赖钉死,避免 jar 包打架

SSH 项目最容易出的问题就是 jar 包冲突,尤其是 Struts2 和 Hibernate 都依赖的 javassist、commons-lang 这些。用 Maven 统一管理,版本写在pom.xml里,比手动往WEB-INF/lib塞 jar 靠谱得多。

<dependencies> <!-- Struts2 核心,注意排除 javassist 避免和 Hibernate 冲突 --> <dependency> <groupId>org.apache.struts</groupId> <artifactId>struts2-core</artifactId> <version>2.5.30</version> <exclusions> <exclusion> <groupId>javassist</groupId> <artifactId>javassist</artifactId> </exclusion> </exclusions> </dependency> <!-- Spring 整合 Hibernate 的 ORM 模块 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-orm</artifactId> <version>4.3.30.RELEASE</version> </dependency> <!-- MySQL 驱动,8.0 用 com.mysql.cj.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> </dependencies>

这里的关键动作是exclusions排除 javassist。Struts2 自带的 javassist 版本往往和 Hibernate 需要的不一致,不排除的话启动时报NoSuchMethodError或者ClassNotFoundException,而且报错位置飘忽,很难定位。MySQL 驱动 8.0 之后类名从com.mysql.jdbc.Driver变成com.mysql.cj.jdbc.Driver,连接串还要加时区参数,否则报时区错误。

3. 供应链核心表怎么设计:采购、库存、供应商三张主线的字段取舍

3.1 采购单主表和明细表的一对多拆分

供应链系统的数据模型,核心就三条线:采购、库存、供应商。采购单是最典型的头明细结构,一张采购单对应多条商品明细,必须拆成两张表。

-- 采购单主表:记录单据级别的信息 CREATE TABLE t_purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '采购单号,业务唯一', supplier_id BIGINT NOT NULL COMMENT '供应商ID', total_amount DECIMAL(12,2) DEFAULT 0.00 COMMENT '采购总金额', status TINYINT DEFAULT 0 COMMENT '0待审 1已审 2已入库 3已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, audit_time DATETIME COMMENT '审批时间,未审批为NULL' ) ENGINE=InnoDB; -- 采购明细表:一行一个商品 CREATE TABLE t_purchase_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT '关联主表ID', goods_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, unit_price DECIMAL(10,2) NOT NULL, INDEX idx_order (order_id) ) ENGINE=InnoDB;

主表存单据级信息,明细表存商品级信息,这是头明细结构的标准做法。order_no加唯一索引,是因为采购单号是业务主键,重复了会导致对账混乱。status用 TINYINT 而不是字符串,查询和索引效率更高,但要在代码里维护好状态枚举,别让 0 和 1 的含义在前后端对不上。total_amount冗余存一份,是为了列表页不用每次去 sum 明细,代价是明细变动时要同步更新,这个同步逻辑必须放在同一个事务里。

3.2 库存表为什么不能只存一个数量

新手最容易犯的错,是库存表只设计一个quantity字段。实际业务里,库存至少有「可用量」和「占用量」两个概念。采购单审批通过但还没入库时,这批货是「在途」;销售出库但还没发货时,是「占用」。只存一个数量,根本算不清。

CREATE TABLE t_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL UNIQUE, available_qty INT DEFAULT 0 COMMENT '可用库存', locked_qty INT DEFAULT 0 COMMENT '锁定库存,如已下单未出库', in_transit_qty INT DEFAULT 0 COMMENT '在途库存,采购已审未入库', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB;

goods_id加唯一约束,保证一个商品只有一条库存记录,避免并发插入产生重复行。三个数量字段分开存,业务含义清晰:真正能卖的是available_qty,下单时从可用挪到锁定,出库时锁定减少,入库时在途减少、可用增加。update_time用ON UPDATE CURRENT_TIMESTAMP自动维护,排查库存异常时能看出最后一次变动时间。

3.3 供应商表和商品表的关联设计

供应商和商品是多对多关系,一个供应商供多种商品,一种商品也可能有多个供应商。中间表不能省。

CREATE TABLE t_supplier ( id BIGINT PRIMARY KEY AUTO_INCREMENT, supplier_name VARCHAR(100) NOT NULL, contact_person VARCHAR(50), phone VARCHAR(20), status TINYINT DEFAULT 1 COMMENT '1合作中 0已停用' ) ENGINE=InnoDB; -- 供应商-商品关联表 CREATE TABLE t_supplier_goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, supplier_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, supply_price DECIMAL(10,2) COMMENT '该供应商的供货价', UNIQUE KEY uk_sup_goods (supplier_id, goods_id) ) ENGINE=InnoDB;

关联表上建(supplier_id, goods_id)联合唯一索引,防止同一对关系重复插入。supply_price放在关联表而不是商品表,是因为同一商品不同供应商报价不同,采购时按供应商取价。供应商的status字段很关键,停用的供应商不能出现在新建采购单的下拉列表里,但历史单据还要能查到它的名字,所以用逻辑删除而不是物理删除。

4. 采购到入库的完整链路:Service 层事务和 Hibernate 映射怎么写

4.1 实体类与 hbm.xml 映射的关键配置

Hibernate 的映射有两种写法:注解和 XML。SSH 老项目里 XML 更常见,因为改映射不用重新编译。以采购单主表为例:

<!-- PurchaseOrder.hbm.xml --> <hibernate-mapping> <class name="com.sc.entity.PurchaseOrder" table="t_purchase_order"> <id name="id" column="id"> <generator class="native"/> </id> <property name="orderNo" column="order_no" length="32" not-null="true"/> <property name="totalAmount" column="total_amount" type="big_decimal"/> <property name="status" column="status" type="byte"/> <!-- 一对多:一张采购单对应多条明细,cascade 让保存主表时级联保存明细 --> <set name="items" cascade="save-update" lazy="true" inverse="true"> <key column="order_id"/> <one-to-many class="com.sc.entity.PurchaseItem"/> </set> </class> </hibernate-mapping>

generator用native,让 Hibernate 根据数据库自动选自增策略,MySQL 下就是 auto_increment。inverse="true"表示关联关系由明细表那一端维护,主表不主动发 update,避免多一条无用 SQL。cascade="save-update"让保存采购单时自动保存明细,但注意别配成all,否则删主表会连带删明细,采购单一般不允许物理删除。lazy="true"延迟加载,列表页只查主表,进详情页才查明细,性能差别很大。

4.2 采购单审批与入库的事务处理

审批和入库是两个独立动作,但入库会同时改采购单状态和库存,必须在一个事务里。Service 层写法:

@Service public class PurchaseServiceImpl implements PurchaseService { @Autowired private PurchaseOrderDao orderDao; @Autowired private StockDao stockDao; // 审批:只改状态,简单事务 @Transactional public void audit(Long orderId) { PurchaseOrder order = orderDao.get(orderId); if (order.getStatus() != 0) { throw new BizException("单据状态不允许审批"); } order.setStatus((byte) 1); order.setAuditTime(new Date()); orderDao.update(order); } // 入库:改状态 + 加库存 + 减在途,多表操作必须同一事务 @Transactional public void stockIn(Long orderId) { PurchaseOrder order = orderDao.get(orderId); if (order.getStatus() != 1) { throw new BizException("只有已审批的单据才能入库"); } for (PurchaseItem item : order.getItems()) { // 悲观锁锁定库存行,防止并发入库导致数量错乱 Stock stock = stockDao.getForUpdate(item.getGoodsId()); stock.setAvailableQty(stock.getAvailableQty() + item.getQuantity()); stock.setInTransitQty(stock.getInTransitQty() - item.getQuantity()); stockDao.update(stock); } order.setStatus((byte) 2); orderDao.update(order); } }

@Transactional加在方法上,Spring 会在方法进入时开事务、正常返回时提交、抛 RuntimeException 时回滚。注意默认只对运行时异常回滚,如果抛的是受检异常要显式配rollbackFor。入库时用getForUpdate走SELECT ... FOR UPDATE加行锁,是因为同一商品可能被多张采购单同时入库,不加锁会出现两个线程读到同一个旧值、各自加一遍、最后少加一次的情况。这是库存系统最典型的并发坑,血泪经验。

4.3 Struts2 Action 接收参数与返回 JSON

前端提交采购单,Action 负责接参数、调 Service、返回结果。Struts2 的 Action 是单例多线程的,成员变量会被并发访问,所以业务数据不要放成员变量,用 ModelDriven 或者方法参数传。

public class PurchaseAction extends ActionSupport { private Long orderId; private Map<String, Object> result = new HashMap<>(); @Autowired private PurchaseService purchaseService; // 入库接口,返回 JSON 给前端 public String stockIn() { try { purchaseService.stockIn(orderId); result.put("code", 200); result.put("msg", "入库成功"); } catch (BizException e) { result.put("code", 500); result.put("msg", e.getMessage()); } return "json"; } public void setOrderId(Long orderId) { this.orderId = orderId; } public Map<String, Object> getResult() { return result; } }

Action 里只做参数接收和结果封装,业务逻辑全在 Service,这是分层的底线。result用 Map 装返回数据,配合 Struts2 的 json 插件序列化。注意 Action 由 Spring 管理时,要在struts.xml里配class为 Spring bean 名,并且 Action 的 scope 要设成 prototype,否则单例下成员变量会串数据。

5. 部署与联调避坑:认证失败、乱码、事务不生效的排查清单

5.1 数据库连不上:从驱动类名到连接串参数

现象:启动 Tomcat 报Cannot create PoolableConnectionFactory或Access denied for user。原因通常有三个。一是驱动类名写错,MySQL 8.0 必须用com.mysql.cj.jdbc.Driver,写老的com.mysql.jdbc.Driver会警告甚至失败。二是连接串没加时区,报The server time zone value is unrecognized,解决是在 URL 后加?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4。三是用户权限,Access denied要确认用户主机是localhost还是%,以及密码有没有特殊字符需要转义。排查顺序:先看异常第一行,再核对驱动类名,最后用命令行mysql -u sc_user -p手动连一次,能连上说明是配置问题不是权限问题。

5.2 中文乱码:三个地方都要设对

现象:商品名存进数据库变成问号,或者页面显示乱码。原因分三层。数据库层,建库建表要utf8mb4;连接层,JDBC URL 要带characterEncoding=utf8mb4;应用层,JSP 页面头部要<%@ page contentType="text/html;charset=UTF-8" %>,Struts2 要在struts.properties里配struts.i18n.encoding=UTF-8。三层缺一层就乱码。排查方法:直接在 MySQL 命令行SELECT看数据本身对不对,如果命令行显示正常但页面乱码,问题在应用层;如果命令行就是问号,问题在建库或连接层。

5.3 事务不生效:自调用和配置扫描的坑

现象:入库时库存改了但采购单状态没改,或者抛异常后数据没回滚。原因最常见的是 Service 内部方法自调用。比如stockIn里直接调this.audit(),这样走的是对象内部调用,不经过 Spring 代理,@Transactional失效。解决是把被调方法抽到另一个 Service,或者用AopContext.currentProxy()拿代理对象调。另一个原因是事务注解没被扫描到,检查applicationContext.xml里<tx:annotation-driven>和组件扫描的包路径有没有覆盖到 Service。排查时可以在方法里打日志,看事务是否真的开启。

5.4 懒加载异常:Session 关闭后访问关联对象

现象:列表页正常,点进详情页报LazyInitializationException: could not initialize proxy - no Session。原因是 Hibernate 的 Session 在 Service 方法结束后就关了,而 JSP 里还在访问order.getItems()这种懒加载集合。解决办法有三种:一是把lazy改成false,简单但性能差;二是在 Service 里用Hibernate.initialize(order.getItems())提前加载;三是配OpenSessionInViewFilter,让 Session 在请求全程保持打开。我一般用第二种,按需加载,可控。第三种虽然省事,但会掩盖性能问题,不推荐在生产用。

5.5 并发入库数量对不上:锁的粒度和顺序

现象:压测时库存总数比实际入库少。原因是没加锁或者锁粒度不对。前面提到的SELECT ... FOR UPDATE是行锁,但要注意两点:一是必须在事务里才生效,自动提交模式下锁立刻释放;二是多张采购单入库时,如果商品顺序不一致,可能死锁。解决是入库前对商品 ID 排序,按固定顺序加锁。另外,FOR UPDATE锁的是索引行,如果goods_id没索引会锁全表,所以库存表的goods_id唯一索引不能省。

6. 让这套系统真正可用:从能跑到能扛的几个进阶动作

系统能跑起来只是第一步,供应链系统真正上线还要过几关。第一关是分页。采购单列表数据量大了以后,一次性查全表会拖垮内存。Hibernate 的分页用Query.setFirstResult()和setMaxResults(),配合count查询算总数。注意setFirstResult在数据量大时性能差,因为 MySQL 的LIMIT offset, size在 offset 很大时要扫描前面所有行,优化方向是用游标或者记录上次最大 ID。

第二关是状态机校验。采购单的状态流转必须是单向的:待审→已审→已入库,不能从已入库退回待审。我一般把合法流转写成一个 Map,每次改状态前查一下,不合法直接抛异常。这样比散落在各处的 if 判断好维护,也方便加日志。

第三关是操作日志。供应链系统出了库存差异,必须能查到是谁在什么时候改了什么。建一张t_oper_log表,记录操作人、操作类型、单据 ID、变更前后值。用 Spring AOP 在 Service 方法上切,自动记录,比手动埋点省事。

验证这套系统是否真的可用,我有个习惯:造一批测试数据,跑一遍完整链路——建供应商、建商品、建采购单、审批、入库、查库存、再退货、再查库存,看每一步的数字对不对。特别是退货,很多人只测正向流程,退货时库存回滚逻辑最容易漏。最后用 JMeter 并发调入库接口,看库存总数是否等于所有入库单数量之和,对不上就说明锁有问题。

这套 SSH 方案不新,但供应链这类业务逻辑重、事务要求高的系统,稳定比时髦重要。我踩过最深的坑是早期没加行锁,压测时库存少了几十件,查了两天才定位到并发。后来养成习惯,凡是改数量的操作,先想清楚锁在哪。希望帮到你。

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