简介:这是一套基于SSM框架的Java美特超市进销存管理系统完整源码,面向计算机专业学生与Java初学者,可用于毕业设计、课程设计或企业级项目练手。系统采用Spring+SpringMVC+MyBatis架构,运行于JDK1.8与Tomcat7环境,数据库使用MySQL5.7,配套Maven3.3.9构建,管理员默认账号密码均为admin,前后台入口清晰,便于快速部署与二次开发。压缩包共717个文件,约20.28MB,其中184个Java源文件承载核心业务逻辑,130个Vue组件与44个JS文件构建前端交互,另有162个SVG、55张JPG及34个PNG提供界面素材,29个XML与2个SQL脚本支撑配置与建库,整体结构完整、层次分明。目前已有98人学习关注,适合需要完整进销存业务闭环、希望理解SSM分层设计与前后端分离实现思路的读者参考借鉴。
1. 从一份 SSM 超市进销存源码说起:Java 课设怎么做出生产味
很多 Java 学习者手里都攥着一份「基于 SSM 的 XX 管理系统」的课程设计源码,美特超市进销存管理系统就是其中典型的一类。它表面上是「增删改查 + 登录」,但真正拉开差距的地方在于:进销存这个业务本身对数据一致性、库存扣减、单据流转有硬要求,不是随便堆几个 Controller 就能糊弄过去的。我见过太多人把这类项目跑起来就交差,结果答辩时被问一句「并发下库存扣成负数怎么办」直接卡壳。这篇笔记就顺着这份 SSM 进销存源码,把 Java 课设怎么做出生产味讲清楚——从环境搭建、分层设计、库存扣减的坑,到怎么用一套可复现的步骤把它跑通并讲明白。适合正在做 Java 课程设计、想拿 SSM 练手、或者准备把课设写进简历的在校生和转行者。SSM 框架虽然老,但它是理解 Java Web 分层、事务、MyBatis 映射最好的练手场,比一上来啃 Spring Boot 自动配置要扎实得多。
2. SSM 进销存的分层骨架:为什么这么拆,拆错了会怎样
2.1 进销存业务到底需要哪几张核心表
进销存三个字拆开就是进货、销售、库存。落到数据库上,最少需要这几张表才能把业务闭环跑起来:商品表(product)、供应商表(supplier)、客户表(customer)、进货单主表与明细表(purchase_order / purchase_order_item)、销售单主表与明细表(sale_order / sale_order_item)、库存表(stock)、以及用户与角色表(sys_user / sys_role)。很多人图省事,把进货和销售塞进一张 order 表用 type 字段区分,短期能跑,但一旦要做「进货退货」「销售退货」「调拨」就彻底乱套。我一般会坚持主表 + 明细表的结构,主表存单据头(单号、供应商、总金额、状态、创建时间),明细表存每一行商品(商品 ID、数量、单价、小计)。库存表单独存在,不和商品表混在一起,因为库存是会随单据变动的动态数据,商品表是相对静态的基础数据,混在一起后期做库存流水会非常痛苦。
下面这张表是我做这类项目时固定的核心表清单,字段只列关键项:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| product | id, name, category_id, unit, purchase_price, sale_price | 商品基础信息 |
| stock | id, product_id, quantity, warn_quantity, update_time | 实时库存与预警线 |
| purchase_order | id, order_no, supplier_id, total_amount, status, create_time | 进货单头 |
| purchase_order_item | id, order_id, product_id, quantity, price | 进货明细 |
| sale_order | id, order_no, customer_id, total_amount, status, create_time | 销售单头 |
| sale_order_item | id, order_id, product_id, quantity, price | 销售明细 |
| stock_record | id, product_id, change_type, change_quantity, ref_order_no | 库存流水 |
stock_record 这张流水表是很多人会漏掉的,但它恰恰是排查库存对不上的后悔药。只要每次库存变动都往这张表插一条记录,后面发现库存数字不对,顺着流水一查就知道是哪张单据出的问题。
2.2 SSM 三层怎么分:Controller 别写业务,Service 别碰 HttpServletRequest
SSM 的分层是 Controller → Service → Mapper,这个大家都知道,但实际写的时候最容易犯两个错。第一个错是把业务逻辑写在 Controller 里,比如在进货的 Controller 方法里直接算总金额、直接调 Mapper 扣库存。这样写的后果是:事务加不上,因为 Spring 的声明式事务默认只对 Service 层方法生效;而且一旦要复用这段逻辑(比如定时任务也要进货),就得复制粘贴。第二个错是在 Service 里注入 HttpServletRequest 去拿 session 里的用户 ID,这会让 Service 和 Web 容器绑死,单元测试根本没法跑。正确做法是把用户 ID 作为参数从 Controller 传进 Service。
一个标准的进货 Service 方法大概长这样:
@Service public class PurchaseServiceImpl implements PurchaseService { @Autowired private PurchaseOrderMapper orderMapper; @Autowired private StockMapper stockMapper; @Autowired private StockRecordMapper stockRecordMapper; // 进货入库:插单据 + 加库存 + 记流水,三步必须在一个事务里 @Override @Transactional(rollbackFor = Exception.class) public void createPurchase(PurchaseOrder order, List<PurchaseOrderItem> items, Long operatorId) { // 1. 生成单号并写入单据头 order.setOrderNo(generateOrderNo("PO")); order.setStatus(1); // 1 表示已入库 order.setCreateBy(operatorId); orderMapper.insert(order); // 2. 逐行写明细,同时累加库存 for (PurchaseOrderItem item : items) { item.setOrderId(order.getId()); orderMapper.insertItem(item); // 库存存在则更新,不存在则初始化 int updated = stockMapper.increaseStock(item.getProductId(), item.getQuantity()); if (updated == 0) { stockMapper.initStock(item.getProductId(), item.getQuantity()); } // 3. 记一条库存流水,方便对账 stockRecordMapper.insert(new StockRecord(item.getProductId(), "PURCHASE_IN", item.getQuantity(), order.getOrderNo())); } } }这段代码有三个关键点。第一,@Transactional(rollbackFor = Exception.class)里的 rollbackFor 必须写,因为 Spring 默认只对 RuntimeException 回滚,如果哪天抛了个受检异常,事务不回滚,单据插了库存没加,数据就烂了。第二,increaseStock返回影响行数,为 0 说明库存记录还不存在,需要初始化,这个判断不能省,否则新商品第一次进货会静默失败。第三,流水记录一定要在同一个事务里写,不能异步,否则事务回滚了流水还在,对账时更乱。
2.3 MyBatis 映射文件里最容易写错的三个地方
MyBatis 的 XML 映射是 SSM 项目里 bug 的高发区。第一个坑是 resultMap 和 resultType 混用。如果数据库字段是product_id,实体类是productId,而你用了 resultType,那 productId 永远是 null,因为 MyBatis 默认不会自动做下划线转驼峰,除非你在配置文件里开了mapUnderscoreToCamelCase=true。我一般直接开这个配置,省得每个字段都写 resultMap。第二个坑是#{}和${}用混。#{}是预编译占位符,能防 SQL 注入;${}是字符串拼接,只有动态表名、动态排序字段这种场景才用,而且必须在 Java 层做白名单校验。第三个坑是批量插入没写对,进销存的明细经常要批量插,正确写法是用<foreach>:
<insert id="batchInsertItems" parameterType="java.util.List"> INSERT INTO purchase_order_item (order_id, product_id, quantity, price) VALUES <foreach collection="list" item="item" separator=","> (#{item.orderId}, #{item.productId}, #{item.quantity}, #{item.price}) </foreach> </insert>注意 collection 的值要和 Mapper 接口里@Param指定的名字一致,不写@Param的话单参数 List 默认叫 list,多参数就得显式命名,否则报Parameter 'list' not found,这个报错新手能查半天。
3. 把项目在本地跑起来:环境、配置、启动的完整命令
3.1 JDK、Tomcat、MySQL 的版本选择与安装
SSM 项目对版本比较敏感,选错了启动就报错。我一般用 JDK 8(项目里如果看到javax.*包就是 JDK 8 时代的,别硬上 JDK 17,会报「源发行版 17 需要目标发行版 17」这类编译错误)、Tomcat 8.5 或 9、MySQL 5.7 或 8.0。MySQL 8 要注意驱动包用com.mysql.cj.jdbc.Driver,URL 后面加serverTimezone=Asia/Shanghai,否则时区报错。安装完 MySQL 后建库建表:
# 登录 MySQL mysql -u root -p # 建库,字符集用 utf8mb4 支持 emoji 和生僻字 CREATE DATABASE met_store DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 导入项目自带的 sql 脚本(假设脚本在项目根目录 db 文件夹下) USE met_store; SOURCE /path/to/project/db/met_store.sql;导入脚本后一定要SHOW TABLES;确认表都建出来了,有时候脚本里有外键约束,导入顺序不对会中途失败,但 MySQL 默认不报错继续执行,结果就是少几张表,启动时报「Table doesn't exist」。
3.2 数据库连接与 MyBatis 配置怎么改
打开src/main/resources下的jdbc.properties(有的项目叫db.properties),改这几项:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/met_store?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码参数说明:useUnicode=true&characterEncoding=utf8保证中文不乱码;serverTimezone解决 MySQL 8 的时区异常;useSSL=false避免本地连接时的 SSL 警告。改完检查applicationContext.xml里有没有正确加载这个 properties 文件,常见写法是<context:property-placeholder location="classpath:jdbc.properties"/>,如果这行漏了,占位符${jdbc.url}不会被替换,启动直接报无法解析。
3.3 用 Maven 打包并部署到 Tomcat
确认 pom.xml 里的依赖都下载完整后,执行打包:
# 在项目根目录执行,跳过测试加快速度 mvn clean package -DskipTests # 打包成功后 target 目录下会生成 war 包 ls target/*.war把 war 包丢进 Tomcat 的webapps目录,启动 Tomcat:
# Linux/Mac sh $TOMCAT_HOME/bin/startup.sh # Windows %TOMCAT_HOME%\bin\startup.bat # 实时看启动日志,确认没有报错 tail -f $TOMCAT_HOME/logs/catalina.out日志里看到Deployment of web application archive ... has finished in xxx ms就说明部署成功,浏览器访问http://localhost:8080/项目名/即可。如果 404,先检查 war 包名和访问路径是否一致;如果 500,看 catalina.out 里的异常栈,八成是数据库连不上或 Mapper 没扫描到。
4. 库存扣减与数据一致性:进销存最容易翻车的地方
4.1 并发下库存扣成负数是怎么发生的
销售出库时扣库存,最朴素的写法是先查再改:
// 错误示范:先查后改,并发下必然超卖 Stock stock = stockMapper.selectByProductId(productId); if (stock.getQuantity() >= quantity) { stock.setQuantity(stock.getQuantity() - quantity); stockMapper.updateById(stock); }这段代码在单线程下没问题,但两个销售员同时卖同一件商品时,两人都查到库存是 10,都判断 10 >= 8 成立,然后各自扣 8,最终库存变成 2,实际卖出去 16 件,超卖 6 件。这就是典型的「读-改-写」竞态。解决办法有两种:悲观锁(SELECT ... FOR UPDATE)和乐观锁(版本号或条件更新)。进销存这种写多读少的场景,我更推荐用条件更新,一条 SQL 搞定:
<update id="decreaseStock"> UPDATE stock SET quantity = quantity - #{quantity}, update_time = NOW() WHERE product_id = #{productId} AND quantity >= #{quantity} </update>Service 里判断返回的影响行数:
int rows = stockMapper.decreaseStock(productId, quantity); if (rows == 0) { throw new BusinessException("库存不足,商品ID:" + productId); }这样把「判断库存够不够」和「扣减」合并成一条原子 SQL,数据库层面保证不会扣成负数。返回 0 就抛异常,配合@Transactional整个销售单回滚。
4.2 单据状态流转与事务边界怎么定
进销存的单据一般有「草稿 → 已提交 → 已入库/已出库 → 已作废」几个状态。状态流转必须和库存变动绑定:只有「已入库」才加库存,「已作废」要反向冲减。我见过有人把状态改和库存改分成两个接口,结果前端调了一个没调另一个,库存和单据对不上。正确做法是一个 Service 方法里同时改状态和库存,用事务包住。事务边界要划在 Service 方法上,不要划在 Controller,也不要在 Service 里手动TransactionTemplate嵌套,除非你很清楚传播行为。默认的REQUIRED传播级别已经够用:外层有事务就加入,没有就新建。
4.3 用库存流水表做对账与排查
前面提到的 stock_record 表,在排查时价值极高。每次库存变动都记一条:商品 ID、变动类型(进货入库 / 销售出库 / 退货入库 / 作废冲减)、变动数量(正负)、关联单号、操作时间。当发现某商品库存对不上时,执行:
-- 查某商品的所有库存变动,按时间排序 SELECT change_type, change_quantity, ref_order_no, create_time FROM stock_record WHERE product_id = 1001 ORDER BY create_time DESC; -- 用流水汇总和当前库存对比,看是否一致 SELECT (SELECT quantity FROM stock WHERE product_id = 1001) AS current_stock, (SELECT SUM(change_quantity) FROM stock_record WHERE product_id = 1001) AS record_sum;如果 current_stock 和 record_sum 不相等,说明有库存变动没记流水,或者流水记了但库存没改,顺着时间点就能定位到是哪次操作出的问题。这个习惯我从课设一直带到生产项目,省了无数对账的加班时间。
5. 避坑与排查:SSM 进销存项目里最常见的五个坑
5.1 启动报 NoClassDefFoundError 或 ClassNotFoundException
现象:Tomcat 启动时报找不到某个类,比如org.springframework.web.context.ContextLoaderListener。原因:war 包里没有把依赖打进去,或者 Maven 依赖 scope 写成了 provided 但 Tomcat 里没有对应的 jar。解决:检查 pom.xml 里 spring-web、spring-context 这些依赖的 scope,如果是 provided,要么改成 compile,要么把 jar 放进 Tomcat 的 lib 目录。另外确认mvn package时没有报依赖下载失败,本地仓库里 jar 是完整的。
5.2 中文乱码:从数据库到页面全链路排查
现象:商品名称在页面显示成问号或乱码。原因:可能出在三个环节——数据库字符集、连接 URL 字符集、页面编码。解决:数据库建库时用 utf8mb4;JDBC URL 加characterEncoding=utf8;JSP 页面顶部加<%@ page contentType="text/html;charset=UTF-8" %>;web.xml 里配 CharacterEncodingFilter 强制请求和响应都用 UTF-8。四个地方都对了才不会乱码,缺一个都可能出问题。
5.3 事务不生效:库存扣了但单据没插进去
现象:销售出库时库存减了,但销售单没生成,或者反过来。原因:@Transactional没生效。常见原因有三个——方法不是 public 的(Spring AOP 对非 public 方法不代理);同类内部方法调用(this 调用不走代理);异常被 catch 了没往外抛。解决:确保 Service 方法是 public;不要在 Service 内部直接 this 调另一个带事务的方法,要么注入自己,要么把逻辑抽到另一个 Service;catch 块里如果要回滚,记得TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或重新抛出异常。
5.4 MyBatis 报 Invalid bound statement (not found)
现象:启动不报错,一调 Mapper 方法就报Invalid bound statement (not found)。原因:Mapper XML 没被扫描到,或者 XML 里的 namespace 和 Mapper 接口全限定名不一致,或者方法名对不上。解决:检查applicationContext.xml里mapperLocations配置的路径是否覆盖了 XML 实际位置;检查 XML 的 namespace 是不是接口的全限定名;检查<select id="xxx">的 id 和接口方法名是否完全一致(大小写敏感)。这三个点挨个对一遍,基本能解决。
5.5 页面 404:Controller 映射和视图解析器配置
现象:访问某个功能页面报 404。原因:可能是 Controller 的@RequestMapping路径写错,或者视图解析器 prefix/suffix 配错导致返回的视图名拼出来的路径不对。解决:先看 Tomcat 日志里有没有No mapping found for HTTP request with URI,有的话就是路径问题;没有的话看视图解析器配置,比如prefix=/WEB-INF/jsp/、suffix=.jsp,那 Controller 返回"product/list"实际找的是/WEB-INF/jsp/product/list.jsp,确认这个文件存在。另外注意@ResponseBody和返回视图名不能混用,加了@ResponseBody就不会走视图解析器了。
6. 从课设到简历项目:怎么把 SSM 进销存讲出技术深度
把项目跑通只是第一步,真正让它值钱的是你能讲清楚里面的技术决策。面试官问「你这个进销存怎么保证库存不超卖」,你要是能答出「用条件更新UPDATE stock SET quantity = quantity - ? WHERE product_id = ? AND quantity >= ?,靠数据库行锁保证原子性,返回影响行数为 0 就抛异常回滚」,这比背八股文有说服力得多。再比如问「事务怎么控制的」,你可以讲@Transactional的 rollbackFor 为什么要写 Exception、同类调用为什么失效、事务边界为什么划在 Service。这些都是你亲手踩过才能讲明白的细节。
我一般还会在项目里加一个小的压测验证,用 JMeter 或者简单的多线程代码模拟 100 个并发销售请求,看最终库存是不是刚好等于初始库存减去总销量。下面这段代码可以直接拿来验证:
// 并发扣减库存验证:100 个线程各扣 1 件,初始库存 100,最终应为 0 public class StockConcurrencyTest { public static void main(String[] args) throws InterruptedException { int threads = 100; CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); AtomicInteger success = new AtomicInteger(0); AtomicInteger fail = new AtomicInteger(0); for (int i = 0; i < threads; i++) { pool.submit(() -> { try { // 调用销售 Service 的扣库存方法 boolean ok = saleService.decreaseStock(1001L, 1); if (ok) success.incrementAndGet(); else fail.incrementAndGet(); } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); System.out.println("成功:" + success.get() + ",失败:" + fail.get()); // 再去数据库查 stock 表,quantity 应该等于 0 } }跑完这个测试,如果成功数是 100、库存是 0,说明扣减逻辑没问题;如果成功数超过 100 或者库存变成负数,说明你的条件更新没写对。这个验证过程本身就是很好的面试素材,因为它证明你不只是「跑通了」,而是「验证过正确性」。
最后说个我自己的习惯:每做完一个课设项目,我都会把踩过的坑和对应的解决方案记在一个 markdown 文件里,跟着项目一起提交。下次做类似项目时翻出来看,能省掉大量重复排查的时间。SSM 进销存这个方向,技术栈虽然不新,但业务逻辑的严谨性训练是实打实的,把库存一致性、事务边界、单据流转这三件事吃透,比多刷十道八股文管用。希望帮到你。
本文还有配套的精品资源,点击获取