简介:面向百货零售业的供应链管理系统项目,基于 Java + SSM 整合 Vue/JSP 技术栈构建,覆盖采购、库存、销售等核心业务环节,适合用作毕业设计或 Java Web 综合实战参考。压缩包共 924 个文件,大小约 9.97MB,主要包含 136 个 Java 后端源码、72 个 JSP 页面、224 个 JS 脚本与 102 个 CSS 样式文件,另有 2 个 SQL 数据库脚本、项目功能说明文档及 Eclipse 工程配置,目录结构清晰,便于导入与二次开发。系统技术栈涉及 Spring、SpringMVC、MyBatis、Vue 组件化开发,可帮助学习者深入理解前后端交互、SSM 整合流程及供应链模块设计。资源经过严格调试,可直接部署运行,并附数据库脚本和功能文档,能快速搭建演示环境。已有 3522 人学习,适合需要完整项目源码的学生与开发者参考。
1. 拿到压缩包之后:先看懂这套百货供应链的SSM骨架
解压ssm685百货中心供应链管理系统+jsp-lw.zip之后,大概率会看到一个标准的Maven工程:src/main/java下面分层放着controller、service、mapper,src/main/webapp/WEB-INF/jsp里躺着所有页面,根目录还有一份带lw字样的说明文档。这套组合就是典型的SSM框架项目——Spring管对象、Spring MVC管请求分发、MyBatis管数据库读写,视图层用JSP渲染,是近十年Java毕业设计和中小型企业内部系统最常见的结构。百货中心的供应链管理,业务上绕不开采购、入库、库存、销售、供应商这几张核心表,系统要做的事就是把它们串成闭环。这篇博文不评价项目的代码质量,只讲清楚一件事:拿到这类SSM+JSP项目后,怎么快速建立数据模型和请求链路的认知,然后改造成自己能跑、能讲、能扩展的状态。
2. 数据先于代码:供应链表结构与MyBatis映射的设计逻辑
2.1 百货供应链最少需要几张表
百货中心做供应链管理,核心是把商品从供应商送到消费者手里,中间经过仓库。最少得有四类数据:供应商、商品、入库/采购、出库/销售。常见做法是再加一张库存表或者直接在商品表冗余库存字段,小系统为了减少join查询,通常会直接在goods表里放stock字段。
CREATE TABLE `supplier` ( `sid` int(11) NOT NULL AUTO_INCREMENT, `sname` varchar(50) NOT NULL, `contact` varchar(20) DEFAULT NULL, `phone` varchar(20) DEFAULT NULL, `address` varchar(100) DEFAULT NULL, PRIMARY KEY (`sid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `goods` ( `gid` int(11) NOT NULL AUTO_INCREMENT, `gname` varchar(50) NOT NULL, `price` decimal(10,2) NOT NULL, `stock` int(11) NOT NULL DEFAULT '0', `sid` int(11) DEFAULT NULL, `category` varchar(30) DEFAULT NULL, PRIMARY KEY (`gid`), KEY `fk_supplier` (`sid`), CONSTRAINT `fk_supplier` FOREIGN KEY (`sid`) REFERENCES `supplier` (`sid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这两张表是基础,采购单和销售单需要单独建表。采购单记录每次从供应商进货的商品、数量和入库时间,销售单记录客户买走的商品、数量和出库时间。订单表里的外键指向goods.gid,不要直接存商品名称字符串,否则供应商改价或者商品改名后历史数据会出现不一致。
CREATE TABLE `purchase_order` ( `poid` int(11) NOT NULL AUTO_INCREMENT, `gid` int(11) NOT NULL, `quantity` int(11) NOT NULL, `unit_price` decimal(10,2) NOT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `status` tinyint(1) DEFAULT '1', PRIMARY KEY (`poid`), KEY `fk_goods` (`gid`), CONSTRAINT `fk_goods` FOREIGN KEY (`gid`) REFERENCES `goods` (`gid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;MySQL 8.0以下版本推荐utf8mb4,原因不复杂:百货商品的描述信息里可能带上emoji或者生僻字,utf8mb3(也就是常说的utf8)存不了四个字节的字符。设计表时要注意,供应商和商品是一对多关系,这个关系在后续的JSP下拉框联动中会用到。
2.2 MyBatis Mapper如何对应到JSON和页面字段
SSM项目里的MyBatis层,通常一个GoodsMapper.xml对应一张表的CRUD。核心是写一个分页查询和库存变更的SQL,这两段是供应链系统的重头戏。分页查询在MyBatis里用手写LIMIT最直接,PageHelper插件虽然方便,但老项目里未必引了依赖。
<select id="selectGoodsPage" resultType="com.demo.entity.Goods"> SELECT g.gid, g.gname, g.price, g.stock, g.category, s.sname AS supplierName FROM goods g LEFT JOIN supplier s ON g.sid = s.sid <where> <if test="gname != null and gname != ''"> AND g.gname LIKE CONCAT('%', #{gname}, '%') </if> <if test="category != null and category != ''"> AND g.category = #{category} </if> </where> ORDER BY g.gid DESC LIMIT #{offset}, #{pageSize} </select>#{offset}和#{pageSize}分别代表跳过的行数和每页条数,前端传页码过来时,Controller里做一个简单换算:offset = (currentPage - 1) * pageSize。这里容易踩的坑是LIMIT后面不能直接用#{}拼写列名或关键字,MyBatis的预编译只处理参数值,不处理表名和列名。resultType直接映射到Java实体类,实体类里如果加了supplierName这个属性,注意left join出来的别名必须和属性名严格一致,否则数据返回null。
Mapper接口和XML的绑定是另一个高频故障点,对应关系的代码长这样:
public interface GoodsMapper { List<Goods> selectGoodsPage(@Param("gname") String gname, @Param("category") String category, @Param("offset") int offset, @Param("pageSize") int pageSize); }XML里<select>的id必须与接口方法名完全一样,namespace指向接口的全限定名,这两个地方对不上,启动Tomcat时MyBatis绑定异常就会直接报错。用@Param注解传多个参数是通用做法,比用Map更安全,因为XML里写#{gname}时编译器能校验到类型。
2.3 库存扣减为什么不能先查再改
供应链系统的库存逻辑是并发事故重灾区。商品表里如果只有stock字段,记库存的减法时不能用SELECT stock查出当前值,然后在Java里算出新值再UPDATE,这种思路在单人操作时没问题,两个管理员同时卖同一件商品时就会超卖。正确做法是把扣减写进一条SQL的条件里:
UPDATE goods SET stock = stock - #{quantity} WHERE gid = #{gid} AND stock >= #{quantity}这条SQL的返回值是受影响的行数,等于1说明扣减成功,等于0说明库存不足或商品不存在。Service层判断这个返回值,失败就抛业务异常,JSP页面捕获后提示用户。这个写法在很多老项目里没有,改造时建议优先补上。想要更强的保障就用SELECT ... FOR UPDATE锁行,但小系统里stock >= #{quantity}条件配合行锁已经够用。
3. JSP视图层与Controller的参数流转:从表单到数据库的完整链路
3.1 请求从哪里来:JSP表单的name与Controller的参数绑定
百货供应链系统里最常见的操作是做一张采购单。JSP页面用表单提交数据,每个<input>标签的name属性值就是Controller方法参数的绑定依据,这个对应关系是SSM里最基础也最容易忽略的规则。
<form action="${pageContext.request.contextPath}/purchase/add" method="post"> <input type="hidden" name="gid" value="${goods.gid}" /> <select name="supplierId"> <c:forEach items="${supplierList}" var="sup"> <option value="${sup.sid}">${sup.sname}</option> </c:forEach> </select> <input type="number" name="quantity" min="1" required /> <input type="text" name="remark" maxlength="100" /> <button type="submit">提交采购单</button> </form>对应的Controller接收参数,常见写法是用JavaBean直接接收,也可以用@RequestParam逐个接收。JavaBean方式更贴合这套项目的习惯,因为页面字段与实体属性一一对应时,Spring MVC会自动完成类型转换和注入。
@Controller @RequestMapping("/purchase") public class PurchaseController { @Autowired private PurchaseService purchaseService; @PostMapping("/add") public String addPurchase(PurchaseOrder order, RedirectAttributes attr) { boolean success = purchaseService.createPurchaseOrder(order); if (success) { attr.addFlashAttribute("msg", "采购单创建成功"); } else { attr.addFlashAttribute("msg", "库存扣减失败,请检查库存量"); } return "redirect:/purchase/list"; } }方法签名里PurchaseOrder order这个参数对象,属性名必须是gid、quantity、remark,对应JSP里的name。RedirectAttributes用来跨重定向传递提示消息,防止刷新页面时表单重复提交。这里的redirect:前缀非常重要——如果直接返回"purchase/list",地址栏还是/purchase/add,F5一刷新就会重复插入一条采购单。
3.2 JSP渲染数据时的El表达式与JSTL标签范围
页面端循环数据一般用c:forEach配合EL表达式取值。$符号的规则是取作用域里的属性,Controller返回的ModelAndView里放了什么,页面就能取到什么。
<table border="1" cellpadding="8"> <tr> <th>商品名称</th><th>采购数量</th><th>单价</th><th>创建时间</th><th>操作</th> </tr> <c:forEach items="${purchaseList}" var="po"> <c:if test="${po.status == 1}"> <tr> <td>${po.goodsName}</td> <td>${po.quantity}</td> <td>${po.unitPrice}</td> <td><fmt:formatDate value="${po.createTime}" pattern="yyyy-MM-dd HH:mm:ss"/></td> <td><a href="${pageContext.request.contextPath}/purchase/detail/${po.poid}">详情</a></td> </tr> </c:if> </c:forEach> </table>注意c:if的test条件里,po.status == 1这个写法,EL表达式里数字会被当作Long类型处理,JSP页面不会自动把整形转成Integer,所以实体类里status字段要定义成Integer,避免空指针或类型不匹配。fmt:formatDate用来格式化时间,页面显示的createTime类型是java.util.Date时才能生效,如果写的是LocalDateTime,需要额外处理或者改成String类型再显示。
JSP的搜索意图里有个高频问题:修改完页面后浏览器还在显示旧内容。这套老项目通常用Tomcat部署,IDE里改JSP文件后有时需要重启或清理work目录——JSP会被Tomcat编译成Java文件再编译成Class,存放在CATALINA_HOME/work/Catalina/localhost/应用名/org/apache/jsp目录下,改完页面不刷新时,去这个目录删掉对应的编译产物即可,这算是一个JSP改后不生效的排查手段。
3.3 Controller返回JSON给页面做异步刷新
供应链管理系统的供应商下拉框,在商品页和采购页都会用到。传统做法是页面加载时用EL直接渲染supplierList,数据量几百条没问题,但如果有二级联动(选供应商后再加载该供应商的商品),就需要Controller返回JSON,JSP用AJAX异步获取。Spring MVC对JSON的原生支持来自@ResponseBody注解:
@GetMapping("/supplier/goods") @ResponseBody public List<Goods> getGoodsBySupplier(@RequestParam("sid") Integer sid) { return goodsService.getGoodsBySupplierId(sid); }只要pom.xml里引入了jackson-databind依赖,返回值就会被自动序列化成JSON数组。页面端配合jQuery发起请求,数据渲染到<select>里即可。有不少SSM项目用fastjson代替jackson,但期间闹出过反序列化漏洞,现阶段的建议是用jackson或者gson。JSON处理在JSP页面里还有一个高频搜索点是jsp jsonarray import,这个通常指在JSP里导入Java的JSON工具类,老项目里会直接<%@ page import="net.sf.json.JSONArray" %>,但这写法不推荐——逻辑应该写到Controller或者Service里,JSP只做显示。
4. 部署运行时必踩的坑:Tomcat版本、JDK编译与数据一致性
4.1 从zip到能访问:war包能跑起来的三要素
解压ssm685百货中心供应链管理系统+jsp-lw.zip之后,先把目录结构弄成Maven标准布局,确认pom.xml里JDK版本,然后二选一:用IDEA打开直接跑,或者用命令行Maven打包成war放进Tomcat。比较稳妥的方式是在IDEA里配置Tomcat运行,这套SSM项目能跑起来的组合通常锁定JDK 8 + Tomcat 8.5/9 + MySQL 5.7,原因在于老项目里的依赖版本大多基于JDK 8编译,比如CGLIB代理和JSP编译器在新JDK上偶发不兼容。
pom.xml里关键的坐标配置长这样:
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.1.20.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.1.20.RELEASE</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> </dependencies>Spring 5.x版本要求JDK 8以上,Tomcat 9对应Servlet 4.0规范,而Spring MVC的DispatcherServlet在这个容器里不需要额外引javax.servlet-api的依赖,容器自带。MySQL驱动选5.1.49配合MySQL 5.7最稳,如果用的是MySQL 8.0,驱动需要升到com.mysql.cj.jdbc.Driver并把URL改造成jdbc:mysql://localhost:3306/ssm_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai——少了serverTimezone参数会直接抛时区错误,这是老项目迁移MySQL 8.0时最容易卡住的位置。
4.2 启动报错看哪几行:驱动类、端口、JDBC驱动版本
运行过程中有四个高频报错,按频率排,每个对应的原因和处理手段都不同。第一个是ClassNotFoundException: com.mysql.jdbc.Driver,字面意思是没有这个驱动类,实际是mysql-connector-java依赖没打进war包,或者版本太高导致类路径变化,解决办法是检查WEB-INF/lib下是否有jar包。
第二个是Port 8080 already in use,这是端口占用。Windows下用netstat -ano | findstr "8080"查出PID,然后taskkill /PID 进程号 /F结束进程。Linux下用lsof -i:8080定位,kill -9处理。换端口就改Tomcat的server.xml里的<Connector port="8080">,注意同时核对项目代码里有没有写死端口号,比如数据库连接URL里误写端口。
第三个是Public Key Retrieval is not allowed,这是MySQL 8.0的驱动与数据库握手时要求获取RSA公钥,但连接URL没开启对应开关。解决办法是URL末尾加allowPublicKeyRetrieval=true。第四个是JSP编译错误,页面报Unable to compile class for JSP,这种一般是JDK版本太高,Tomcat 9以下的容器无法用JDK 11+编译JSP,或者是JSP文件里用了JDK 8之后移除的类,比如StringUtils的旧包路径。所以最省力的组合就是JDK 8,能避开八成的兼容问题。
4.3 数据一致性:一个采购事务里的两张表
供应链系统里一次采购操作要同时影响purchase_order和goods两张表,这就涉及事务。老项目里常见的是写只更新订单,不扣库存;或者扣了库存但订单失败了,库存改不回来。SSM的事务控制要在Service方法上加@Transactional注解:
@Service public class PurchaseServiceImpl implements PurchaseService { @Autowired private PurchaseOrderMapper purchaseOrderMapper; @Autowired private GoodsMapper goodsMapper; @Override @Transactional(rollbackFor = Exception.class) public boolean createPurchaseOrder(PurchaseOrder order) { int updated = goodsMapper.reduceStock(order.getGid(), order.getQuantity()); if (updated == 0) { throw new BusinessException("库存不足"); } return purchaseOrderMapper.insert(order) > 0; } }@Transactional(rollbackFor = Exception.class)的语义是运行时异常和检查异常都能触发回滚。小坑在于Spring声明式事务默认只对RuntimeException回滚,加异常时如果不把rollbackFor配上,业务抛出的Exception子类根本不会回滚,数据库里就出现订单有了、库存没减的一致性问题。另一个验证方法是看控制台有没有Creating new transaction日志,没有说明事务管理器没生效,检查Spring配置文件里<tx:annotation-driven transaction-manager="transactionManager"/>是否配置。
事务在并发量不高的管理系统里性能损耗可控,不要为了省事去掉事务。@Transactional加在private方法上是无效的,这点是很多JSP老项目改造时的隐含bug——注解只有加在public方法上且通过代理调用才会被拦截,同类内部调用不会走代理。
5. 压轴的优化手法:库存扣减与页面缓存的工程化写法
5.1 用乐观锁改造扣库存的最优解
在2.3节的stock >= #{quantity}写法基础上,再叠加一个乐观锁版本号字段。供应链系统的库存扣减在高并发下需要更严谨的防线,version字段每次更新加1,更新条件里带AND version = #{version},影响行数为0则重试,标准的CAS操作。
ALTER TABLE goods ADD COLUMN version INT NOT NULL DEFAULT 0; UPDATE goods SET stock = stock - #{quantity}, version = version + 1 WHERE gid = #{gid} AND stock >= #{quantity} AND version = #{version}Service层拿到更新行数,为0就抛异常让用户重新提交。版本号不必暴露给前端,从商品详情查出来放在表单的隐藏字段里即可。这么做能防止ABA问题——库存被两个请求读出相同的值又写回,导致丢失一次扣减。
反映到JSP页面,商品详情按钮跳转时带上version参数,采购表单把这个字段作为隐藏输入项提交,这条链路就完整了。
5.2 JSP页面响应速度的三个手段
百货系统的商品列表页往往会随着数据量增大变得越来越卡,第一个手段是JSP片段缓存,商品分类导航、供应商信息这类不常变的内容用<c:import>或JSTL的<c:cache>做片段缓存,注意<c:cache>需要引入jstl的cache标签库,很多老项目没有,用Apache Commons JSP Tags替代来给页面加时间缓存。第二个手段是SQL层面及时分页,前端LIMIT只查当前页数据,禁止查出全部List再在内存里截取。第三个手段是把商品图片、静态CSS和JS从JSP目录移到独立的静态资源路径,用Nginx这类Web服务器做静态文件响应,Tomcat只处理动态请求,压力会下降明显。
5.3 改完之后的验证清单
部署优化完成后,至少要验证三条主链路。先验证一条商品浏览路径:点开首页,搜索商品,进详情,看库存量是否与数据库一致。再验证一条采购链路:提交采购单,库存减少,采购列表出现新记录,供应商名称正确显示。最后验证并发扣减:用JMeter或Postman并发发5个相同的采购请求,库存只会扣减成功一次,订单表里只有一条记录,其余请求提示库存不足。验证时把日志级别调整到DEBUG,重点观察MyBatis输出的SQL中UPDATE goods SET stock = stock - ?的affected rows返回值,结合事务日志看是否有提交回滚记录。
一套SSM+JSP的供应链系统,掌握了请求从JSP到Controller再到Mapper再回到页面的完整回路,理解了库存扣减的数据一致性问题,剩下的就是在这个骨架上继续加报表、加登录权限、加供应商结算模块。每个版本迭代都从最小可运行状态开始改,改一步验证一步,比推翻重来更能积累可复用的技术资产。
本文还有配套的精品资源,点击获取