简介:这是一套面向计算机专业毕业设计场景的SSM超市商品信息管理系统完整程序包,适合正在准备毕设的本科生或需要Java Web实战练手的开发者,可解决选题落地、代码参考与数据库设计等实际问题。压缩包共653个文件,约10.57MB,以134个java源码、76个vue与76个html页面、62个js脚本、82张jpg与75个gif素材为主,另含26个xml配置、19个css样式、2个sql数据库脚本及class、properties等编译与配置文件,前后端结构完整。已有837人学习下载,说明该方案在同类毕设中具备一定参考价值。资源包含程序源代码一份与数据库一份,可完美运行,配置环境说明清晰,覆盖需求分析、总体设计、数据库访问实现及功能模块关键代码,读者可据此理解控制层、业务层与工具类的组织方式,快速完成环境搭建与二次开发,遇到运行问题还可私信作者获取帮助。
1. 从一份“能跑起来”的超市商品管理系统说起
很多同学做毕业设计时,最头疼的不是写代码,而是“系统到底该长什么样”。导师一句“做个超市商品信息管理系统”,听起来简单,真动手就懵了:商品分类怎么设计?库存扣减要不要做事务?SSM 三个框架到底怎么串起来?更现实的问题是——代码写完了,答辩时被问“你这个库存并发怎么处理的”,答不上来就尴尬了。
SSM 超市商品信息管理系统毕业设计程序,本质是一套基于 Spring + SpringMVC + MyBatis 的 Java Web 应用,核心解决的是商品信息增删改查、分类管理、库存预警和基础统计。它适合两类人:一是需要完整项目撑起毕业设计的同学,二是想通过一个真实业务场景把 SSM 框架串起来练手的开发者。这篇文章不讲空泛的“系统概述”,而是按实际开发顺序,把数据库设计、框架整合、核心业务实现和答辩高频问题拆开讲,让你能照着复现,也能在答辩时说出“为什么这么做”。
2. 数据库设计与 SSM 整合:先把地基打牢
2.1 商品表、分类表、库存表的字段怎么定
很多同学一上来就写 Controller,结果写到一半发现字段不够用,回头改表结构,Mapper 和前端全要跟着动。我的习惯是先把核心表定死,再动代码。超市商品管理最少需要三张表:商品分类表、商品信息表、库存变更记录表。
商品分类表category保持简单,字段包括id、name、sort_order、create_time。sort_order用于前端排序,避免直接按 id 排导致新增分类跑到最后。
商品信息表product是核心,字段设计要兼顾查询和展示:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| product_no | varchar(32) | 商品编号,唯一索引 |
| name | varchar(64) | 商品名称 |
| category_id | bigint | 外键,关联分类 |
| price | decimal(10,2) | 售价,不要用 float |
| cost_price | decimal(10,2) | 进价,用于毛利统计 |
| stock | int | 当前库存 |
| stock_warn | int | 库存预警值 |
| status | tinyint | 1 上架,0 下架 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
这里有两个容易翻车的点:价格用decimal而不是float,否则 0.1 + 0.2 的问题会在统计报表里让你怀疑人生;product_no加唯一索引,防止重复录入。
库存变更记录表stock_record用来追溯每一次库存变动,字段包括id、product_id、change_type(1 入库,2 出库,3 盘点调整)、change_num、before_stock、after_stock、operator、create_time。这张表在答辩时是加分项,因为你能说清楚“库存不是随便改的,每次变动都有记录”。
建表 SQL 如下:
CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(32) NOT NULL, `sort_order` int DEFAULT 0, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `product_no` varchar(32) NOT NULL, `name` varchar(64) NOT NULL, `category_id` bigint NOT NULL, `price` decimal(10,2) NOT NULL DEFAULT '0.00', `cost_price` decimal(10,2) NOT NULL DEFAULT '0.00', `stock` int NOT NULL DEFAULT '0', `stock_warn` int NOT NULL DEFAULT '10', `status` tinyint NOT NULL DEFAULT '1', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_product_no` (`product_no`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `stock_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `product_id` bigint NOT NULL, `change_type` tinyint NOT NULL, `change_num` int NOT NULL, `before_stock` int NOT NULL, `after_stock` int NOT NULL, `operator` varchar(32) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_product` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:product表的update_time用ON UPDATE CURRENT_TIMESTAMP,这样每次更新自动刷新,不用在代码里手动 set。stock_record不设外键约束,只加索引,原因是毕业设计环境里外键容易在批量删除时卡住,用索引加业务层校验更灵活。
2.2 Spring + SpringMVC + MyBatis 的整合顺序
SSM 整合最怕的是“配置文件互相打架”。我一般按这个顺序来:先配 MyBatis,再配 Spring,最后配 SpringMVC,每配完一层就写一个最小测试跑通,不要三份配置一起写完再启动。
第一步,jdbc.properties放数据库连接:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/supermarket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456注意serverTimezone必须加,否则 MySQL 8 会报时区错误,这个坑每年都有人踩。
第二步,spring-dao.xml配数据源和 MyBatis:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.demo.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.demo.mapper"/> </bean>参数说明:typeAliasesPackage让实体类在 XML 里不用写全限定名;mapperLocations指定 XML 位置,如果 Mapper 接口和 XML 同名同包,可以省略这行,但毕业设计里分开写更清晰。
第三步,spring-service.xml配 Service 扫描和事务:
<context:component-scan base-package="com.demo.service"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>事务管理器必须配,否则库存扣减出异常时不会回滚,这是答辩高频追问点。
第四步,spring-web.xml配 SpringMVC:
<context:component-scan base-package="com.demo.controller"/> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>default-servlet-handler用来处理静态资源,不加的话 CSS 和 JS 会 404。
最后在web.xml里把三份配置串起来:
<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-dao.xml,classpath:spring-service.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-web.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这样分层的好处是:Dao 和 Service 的配置在 ContextLoaderListener 里加载,Controller 在 DispatcherServlet 里加载,职责清晰,出问题也好定位。
3. 商品管理与库存扣减:核心业务代码怎么写
3.1 商品分页查询与多条件筛选
商品列表不能一次性全查出来,必须分页。MyBatis 分页有两种做法:用 PageHelper 插件,或者手写 limit。毕业设计里我建议手写,因为答辩时老师可能问“分页原理是什么”,你能说清楚 limit 偏移量怎么算。
Mapper 接口:
public interface ProductMapper { List<Product> selectByPage(@Param("keyword") String keyword, @Param("categoryId") Long categoryId, @Param("offset") int offset, @Param("limit") int limit); int countByCondition(@Param("keyword") String keyword, @Param("categoryId") Long categoryId); }XML 映射:
<select id="selectByPage" resultType="Product"> SELECT * FROM product <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR product_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> ORDER BY update_time DESC LIMIT #{offset}, #{limit} </select> <select id="countByCondition" resultType="int"> SELECT COUNT(*) FROM product <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR product_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> </select>逻辑说明:<where>标签会自动处理第一个AND,避免WHERE AND的语法错误。CONCAT('%', #{keyword}, '%')用#{}而不是${},防止 SQL 注入。offset由 Service 层计算:(pageNum - 1) * pageSize。
Service 层:
public PageResult<Product> queryPage(String keyword, Long categoryId, int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; List<Product> list = productMapper.selectByPage(keyword, categoryId, offset, pageSize); int total = productMapper.countByCondition(keyword, categoryId); PageResult<Product> result = new PageResult<>(); result.setList(list); result.setTotal(total); result.setPageNum(pageNum); result.setPageSize(pageSize); return result; }参数说明:pageNum从 1 开始,pageSize建议默认 10,前端传参时做边界校验,防止pageSize传 10000 把数据库拖垮。
3.2 库存扣减的事务与并发处理
库存扣减是这套系统里最容易出问题的地方。常见错误写法是先查库存再更新:
// 错误示范 Product p = productMapper.selectById(id); if (p.getStock() >= num) { p.setStock(p.getStock() - num); productMapper.update(p); }这种写法在并发下会超卖:两个线程同时查到库存 10,都判断通过,各自扣 3,最终库存变成 7,但实际卖出 6,账对不上。
正确做法是用 SQL 原子更新:
<update id="reduceStock"> UPDATE product SET stock = stock - #{num}, update_time = NOW() WHERE id = #{id} AND stock >= #{num} </update>Service 层:
@Transactional(rollbackFor = Exception.class) public void reduceStock(Long productId, int num, String operator) { Product before = productMapper.selectById(productId); if (before == null) { throw new BusinessException("商品不存在"); } int affected = productMapper.reduceStock(productId, num); if (affected == 0) { throw new BusinessException("库存不足"); } Product after = productMapper.selectById(productId); StockRecord record = new StockRecord(); record.setProductId(productId); record.setChangeType(2); record.setChangeNum(num); record.setBeforeStock(before.getStock()); record.setAfterStock(after.getStock()); record.setOperator(operator); stockRecordMapper.insert(record); }逻辑说明:UPDATE ... WHERE stock >= #{num}把判断和更新合并成一条原子操作,InnoDB 行锁保证并发安全。@Transactional保证库存更新和记录插入在同一个事务里,任何一步失败都回滚。rollbackFor = Exception.class确保受检异常也回滚,默认只回滚 RuntimeException。
注意:
before和after的查询不在同一个事务快照里,高并发下after可能被其他事务修改。毕业设计场景够用,但如果要严格一致,应该在reduceStock的 SQL 里用SELECT ... FOR UPDATE锁行,或者把记录插入放到更新之前用触发器。答辩时能说出这个边界,比代码本身更加分。
3.3 库存预警的定时任务与页面提醒
库存预警不需要实时计算,用 Spring 的@Scheduled每天跑一次即可。在spring-service.xml里加:
<task:annotation-driven/>然后写一个任务类:
@Component public class StockWarnTask { @Autowired private ProductMapper productMapper; @Scheduled(cron = "0 0 8 * * ?") public void checkStock() { List<Product> warnList = productMapper.selectWarnProducts(); if (!warnList.isEmpty()) { // 实际项目里发邮件或站内信,毕业设计写日志即可 System.out.println("库存预警商品数量:" + warnList.size()); } } }Mapper 查询:
<select id="selectWarnProducts" resultType="Product"> SELECT * FROM product WHERE stock <= stock_warn AND status = 1 </select>参数说明:cron = "0 0 8 * * ?"表示每天 8 点执行。stock <= stock_warn里的<是 XML 转义,直接写<会解析报错。前端页面在商品列表里对stock <= stock_warn的行加红色背景,这个用 JSTL 判断即可:
<c:if test="${product.stock <= product.stockWarn}"> <tr style="background-color: #ffe6e6;"> </c:if>这样运营人员一眼就能看到哪些商品需要补货,比单纯弹窗提醒更实用。
4. 避坑与排查:那些年我踩过的 SSM 坑
4.1 中文乱码:从数据库到页面的全链路排查
现象:商品名称在数据库里正常,页面上显示问号。
原因:字符集不统一。常见断点有三处——数据库连接 URL 没加characterEncoding=utf8、JSP 页面没声明pageEncoding、Tomcat 的server.xml里 Connector 没加URIEncoding。
解决:按链路逐段检查。数据库连接 URL 加useUnicode=true&characterEncoding=utf8;JSP 头部加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Tomcat 的 Connector 加URIEncoding="UTF-8"。三处都对了,乱码基本消失。
4.2 Mapper 接口和 XML 绑定失败
现象:启动报Invalid bound statement (not found)。
原因:Mapper 接口和 XML 的 namespace 不一致,或者 XML 没被扫描到。
解决:检查 XML 的<mapper namespace="com.demo.mapper.ProductMapper">是否和接口全限定名完全一致;检查mapperLocations路径是否匹配实际文件位置。如果接口和 XML 同名同包,可以省略mapperLocations,但毕业设计里分开写更清晰,记得路径别写错。
4.3 事务不生效:Service 内部调用自己的方法
现象:库存扣减抛异常了,但库存还是被改了。
原因:在同一个 Service 类里,方法 A 直接调用方法 B,B 上的@Transactional不会生效,因为没走代理。
解决:把事务方法抽到另一个 Service 类,或者用AopContext.currentProxy()获取代理对象再调用。最简单的方式是拆类,毕业设计里拆成ProductService和StockService,职责也更清晰。
4.4 分页查询 count 和 list 条件不一致
现象:列表显示 10 条,但总数显示 100 条,翻页翻到后面是空的。
原因:countByCondition和selectByPage的<where>条件写得不一致,比如一个加了status = 1,另一个没加。
解决:把条件抽成<sql>片段,两个查询都引用同一个片段:
<sql id="queryCondition"> <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR product_no LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> </where> </sql>然后两个查询都用<include refid="queryCondition"/>,保证条件永远一致。
4.5 前端传参类型不匹配导致 400
现象:提交商品表单时报 400 Bad Request,后台没进 Controller。
原因:前端传的price是字符串 "12.5",后端实体用BigDecimal接收,SpringMVC 默认转换失败。
解决:在实体字段上加@DateTimeFormat或自定义@InitBinder,或者前端用number类型的 input。更稳妥的做法是在 Controller 里用@RequestParam显式声明类型,并加required = false做兜底。
5. 答辩加分项:把“能跑”变成“能说清楚”
5.1 用 AOP 记录操作日志
毕业设计里加一个操作日志功能,成本低但答辩效果好。用 Spring AOP 拦截 Service 层的增删改方法,记录操作人、操作时间、方法名和参数。
自定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LogRecord { String value() default ""; }切面:
@Aspect @Component public class LogAspect { @Autowired private LogMapper logMapper; @Around("@annotation(logRecord)") public Object around(ProceedingJoinPoint joinPoint, LogRecord logRecord) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; SysLog log = new SysLog(); log.setMethod(joinPoint.getSignature().toShortString()); log.setParams(Arrays.toString(joinPoint.getArgs())); log.setCost(cost); log.setCreateTime(new Date()); logMapper.insert(log); return result; } }参数说明:@Around可以拿到方法执行前后的时间,joinPoint.getSignature().toShortString()输出类名和方法名。日志表sys_log字段包括id、method、params、cost、create_time。答辩时你可以说“系统所有写操作都有日志追溯,方便排查问题”,这比单纯说“我做了增删改查”有说服力。
5.2 用 JMeter 做一次简单的并发验证
库存扣减的并发安全,光说不够,跑一次数据更有底气。用 JMeter 建一个线程组,100 个线程,循环 1 次,请求库存扣减接口,观察最终库存是否等于初始库存减去成功请求数。
验证 SQL:
SELECT stock FROM product WHERE id = 1; SELECT COUNT(*) FROM stock_record WHERE product_id = 1 AND change_type = 2;如果stock等于初始值减去stock_record里出库记录的总数,说明没有超卖。这个验证过程可以截图放进论文的“测试章节”,比纯文字描述更有说服力。
5.3 答辩高频问题与回答思路
导师常问的几个问题,提前准备好答案:
“为什么用 SSM 不用 SpringBoot?”——回答思路:SSM 是理解 Spring 生态的基础,配置过程能体现对 IoC、AOP、事务管理的理解,SpringBoot 只是简化了配置,底层原理一样。
“库存并发怎么处理的?”——回答思路:用 SQL 原子更新UPDATE ... WHERE stock >= num,配合@Transactional保证事务,并说明 InnoDB 行锁的作用。
“分页怎么实现的?”——回答思路:手写 limit,offset = (pageNum - 1) * pageSize,count 和 list 条件用同一个 SQL 片段保证一致。
“如果商品分类删除了,商品怎么办?”——回答思路:业务层校验,分类下有商品时不允许删除,或者做逻辑删除,把status置为 0。
我自己的习惯是,每次写完一个模块,就假设导师坐在对面问三个问题,把答案写在注释里。这样答辩时不用背,顺着代码就能讲。希望帮到你。
本文还有配套的精品资源,点击获取