简介:这份资源是面向高校计算机相关专业学生与Java入门开发者的SSM课程设计参考项目,围绕超市管理场景,解决商品、库存、销售等业务模块从设计到落地的实践需求,适合作为课程作业、毕业设计原型或SSM框架练手案例。压缩包共792个文件,约1.78MB,以452个html页面、41个js脚本、16个css样式构成前端视图层,48个java源文件与48个class编译文件对应后端控制器与实体类,另有28个xml配置、12个jsp页面及1个sql建库脚本,完整覆盖Spring依赖注入与事务管理、Spring MVC请求分发、MyBatis数据库映射的开发链路。已有86人学习下载。项目按商品管理、库存出入库与盘点、销售订单与报表、会员积分及统计报表等模块组织,目录结构清晰,读者可据此理解分层架构、掌握控制器与实体类协作方式,并借助SQL脚本快速还原数据库,为二次开发与答辩演示提供可运行的参考骨架。
1. 从一份“能跑起来的课设”说起:SSM 超市管理系统到底解决了什么
每年到了课程设计季,后台总有人问同一个问题:有没有一个结构完整、能直接跑、还能改的 SSM 项目?答案往往让人失望——网上流传的很多“超市管理系统”要么只有几张截图,要么代码跑不起来,要么把 Spring MVC、MyBatis 的配置写得云里雾里。这份基于 SSM 的超市管理系统,恰好是那种“下载下来、改个数据库连接、就能登录进后台”的完整工程。它把商品管理、库存出入库、供应商维护、收银下单、订单查询这几条超市业务主线串了起来,前端用 JSP 加少量 JavaScript 做页面渲染和表单校验,后端用 Spring MVC 接请求、Spring 管 Bean 和事务、MyBatis 负责 SQL 映射。适合两类人:一类是刚学完 SSM 三件套、想找一个真实业务场景练手的同学;另一类是带课设、需要一份可讲解、可拆改的参考工程的开发者。下面我按“这项目怎么组织、怎么跑、怎么改”的顺序,把它拆开讲清楚。
2. 工程结构与技术栈:一个请求从浏览器到数据库的完整链路
2.1 分层结构与目录约定
这个项目是典型的 Maven Web 工程,打包方式是 war,最终丢进 Tomcat 运行。目录遵循 Maven 标准布局,理解这套约定比记住每个类名更重要,因为后面改代码、加模块都靠它定位。
supermarket/ ├── pom.xml └── src/main/ ├── java/com/shop/ │ ├── controller/ # Spring MVC 控制器,接收前端请求 │ ├── service/ # 业务接口 │ ├── service/impl/ # 业务实现,事务边界在这里 │ ├── mapper/ # MyBatis Mapper 接口 │ ├── entity/ # 实体类,与数据库表对应 │ └── util/ # 分页、日期、MD5 等工具 ├── resources/ │ ├── spring-mvc.xml # 视图解析、静态资源、拦截器 │ ├── spring-service.xml # 扫描 service、事务管理器 │ ├── spring-mybatis.xml # 数据源、SqlSessionFactory │ ├── mybatis/ # Mapper XML 与 mybatis-config │ └── jdbc.properties # 数据库连接参数 └── webapp/ ├── WEB-INF/ │ ├── web.xml # 前端控制器、编码过滤器 │ └── views/ # JSP 页面 └── static/ # css、js、图片分层职责很清晰:controller 只做参数接收和视图跳转,不写业务判断;service 承担业务规则和事务;mapper 只负责 SQL。这种“薄 controller、厚 service”的写法在课设里经常被写反,导致 controller 里塞满 if-else,后面想加一个库存校验就得改三处。这个工程把库存扣减、订单状态流转都放在 service 层,改起来只动一个地方。
2.2 三大配置文件各自管什么
SSM 项目最容易让人翻车的地方就是配置文件分散、职责交叉。这个工程把配置拆成三份,每份只干一件事,读的时候按“数据源 → 业务 → 视图”的顺序看就不会乱。
<!-- spring-mybatis.xml:只负责数据源和 SqlSessionFactory --> <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="mapperLocations" value="classpath:mybatis/*.xml"/> <property name="typeAliasesPackage" value="com.shop.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.shop.mapper"/> </bean>这段配置做了三件事:用 Druid 建连接池、告诉 MyBatis 去哪里找 Mapper XML、把com.shop.mapper下的接口自动注册成 Bean。mapperLocations写的是classpath:mybatis/*.xml,意味着新增一个 Mapper XML 只要放进这个目录就会被加载,不用改配置。typeAliasesPackage让 XML 里可以直接写Product而不用写全限定名,这是减少 XML 冗余的常见做法。
<!-- spring-service.xml:事务和 service 扫描 --> <context:component-scan base-package="com.shop.service"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>事务管理器绑定同一个数据源,开启注解驱动后,service 方法上加@Transactional就能生效。注意这里扫描的是com.shop.service,如果 service 实现类放在别的包下,事务不会生效——这是后面避坑章节要展开的一个高频问题。
<!-- spring-mvc.xml:视图和静态资源 --> <context:component-scan base-package="com.shop.controller"/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <mvc:resources mapping="/static/**" location="/static/"/> <mvc:annotation-driven/>视图解析器把 controller 返回的"product/list"拼成/WEB-INF/views/product/list.jsp,放在 WEB-INF 下是为了防止用户直接通过 URL 访问 JSP。静态资源单独放行,否则会被 DispatcherServlet 拦截导致 css、js 加载 404。
2.3 web.xml 里的两个关键入口
web.xml是整个 Web 应用的启动入口,这个工程里真正需要关注的是编码过滤器和前端控制器。
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <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-mvc.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>forceEncoding=true保证请求和响应都用 UTF-8,中文商品名、供应商名不会变成乱码。url-pattern写成/而不是/*,前者只拦截动态请求、放行静态资源,后者会把静态资源也吞掉。这两个细节在课设答辩时经常被问到,也是新手最容易抄错的地方。
3. 核心业务模块拆解:商品、库存与订单怎么串起来
3.1 商品管理:一个标准 CRUD 的完整写法
商品管理是整个系统的地基,其他模块都围绕商品 ID 展开。先看 controller,它只做三件事:接参数、调 service、返回视图或重定向。
@Controller @RequestMapping("/product") public class ProductController { @Autowired private ProductService productService; // 分页查询,page 从 1 开始,size 默认 10 @RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(required = false) String keyword, Model model) { PageResult<Product> result = productService.page(page, 10, keyword); model.addAttribute("page", result); model.addAttribute("keyword", keyword); return "product/list"; } // 新增商品,成功后重定向到列表,避免刷新重复提交 @RequestMapping(value = "/add", method = RequestMethod.POST) public String add(Product product, RedirectAttributes ra) { productService.save(product); ra.addFlashAttribute("msg", "新增成功"); return "redirect:/product/list"; } }@RequestParam(defaultValue = "1")让分页参数缺省时不会报错,required = false的 keyword 支持按名称模糊搜索。新增用 POST 加重定向,是防止浏览器刷新时重复插入的常规手段,RedirectAttributes把提示信息带到下一个请求,页面刷新后提示消失,不会一直挂着。
service 层负责业务校验和事务:
@Service public class ProductServiceImpl implements ProductService { @Autowired private ProductMapper productMapper; @Override @Transactional public void save(Product product) { // 商品编码唯一性校验,重复直接抛业务异常 if (productMapper.countByCode(product.getCode()) > 0) { throw new BizException("商品编码已存在"); } product.setCreateTime(new Date()); productMapper.insert(product); } @Override public PageResult<Product> page(int page, int size, String keyword) { int offset = (page - 1) * size; List<Product> rows = productMapper.selectPage(offset, size, keyword); int total = productMapper.count(keyword); return new PageResult<>(rows, total, page, size); } }@Transactional加在 save 上,唯一性校验和插入在同一个事务里,虽然这里只有一条写操作,但保持习惯很重要——后面库存扣减涉及多表写入时,事务边界就是靠这一层控制的。分页没有用 PageHelper 插件,而是手写 offset 和 count,好处是 SQL 完全可控,课设答辩时能讲清楚每一行在干什么。
Mapper XML 对应两条 SQL:
<select id="selectPage" resultType="Product"> SELECT id, code, name, price, stock, supplier_id AS supplierId FROM product <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY id DESC LIMIT #{offset}, #{size} </select> <select id="count" resultType="int"> SELECT COUNT(*) FROM product <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> </select><where>标签会自动处理开头的 AND,<if>做动态条件拼接,#{}是预编译占位符,能防 SQL 注入。supplier_id AS supplierId是为了让下划线字段映射到驼峰属性,如果全局开了mapUnderscoreToCamelCase,这行可以省掉,但显式写出来更直观。
3.2 库存出入库:多表写入与事务控制
库存是这个系统里唯一涉及“数量变化”的模块,也是最容易出并发问题的地方。工程里的做法是:每次出入库都写一条流水记录,同时更新商品表的库存字段,两步放在同一个事务里。
@Service public class StockServiceImpl implements StockService { @Autowired private StockMapper stockMapper; @Autowired private ProductMapper productMapper; @Override @Transactional(rollbackFor = Exception.class) public void stockIn(Long productId, int num, String operator) { if (num <= 0) { throw new BizException("入库数量必须大于 0"); } // 先更新库存,再写流水,顺序反了会导致流水有记录但库存没加 int updated = productMapper.increaseStock(productId, num); if (updated == 0) { throw new BizException("商品不存在"); } StockRecord record = new StockRecord(); record.setProductId(productId); record.setType("IN"); record.setNum(num); record.setOperator(operator); record.setCreateTime(new Date()); stockMapper.insert(record); } }rollbackFor = Exception.class是关键,Spring 默认只对 RuntimeException 回滚,业务里抛的受检异常不会触发回滚,加上这个参数才保险。更新库存用increaseStock这种带条件的 UPDATE,而不是先查再改,避免并发下两个请求读到同一个旧值。
<update id="increaseStock"> UPDATE product SET stock = stock + #{num} WHERE id = #{productId} </update> <update id="decreaseStock"> UPDATE product SET stock = stock - #{num} WHERE id = #{productId} AND stock >= #{num} </update>出库的 SQL 带了AND stock >= #{num},库存不足时更新影响行数为 0,service 层据此抛异常回滚。这种“把校验写进 SQL 条件里”的做法,比先查库存再判断更可靠,因为查和改之间没有间隙。代价是错误提示不够细,只能告诉用户“库存不足或商品不存在”,但对课设场景够用了。
3.3 收银下单:订单主从表的一次性写入
下单是业务链路最长的一环:校验库存、生成订单主表、写入订单明细、扣减库存,四步必须原子完成。
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, List<OrderItem> items) { if (items == null || items.isEmpty()) { throw new BizException("订单不能为空"); } BigDecimal total = BigDecimal.ZERO; for (OrderItem item : items) { Product p = productMapper.selectById(item.getProductId()); if (p == null) { throw new BizException("商品不存在:" + item.getProductId()); } // 单价以数据库为准,不信任前端传值 item.setPrice(p.getPrice()); total = total.add(p.getPrice().multiply(new BigDecimal(item.getNum()))); } Order order = new Order(); order.setUserId(userId); order.setTotalAmount(total); order.setStatus("PAID"); order.setCreateTime(new Date()); orderMapper.insert(order); // 主表,回填自增 id for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); int updated = productMapper.decreaseStock(item.getProductId(), item.getNum()); if (updated == 0) { throw new BizException("库存不足:" + item.getProductId()); } } return order.getId(); }单价从数据库重新取,是防止前端篡改价格;订单主表插入后 MyBatis 的useGeneratedKeys会把自增 ID 回填到 order 对象,明细表才能拿到 orderId。任何一步失败,整个方法回滚,不会出现“订单生成了但库存没扣”的脏数据。这段代码是整个项目里最值得反复读的部分,二次开发加优惠券、加积分,都是在这个骨架上挂逻辑。
4. 把项目跑起来:环境、建库与启动排查
4.1 环境准备与依赖版本
这个工程对版本比较敏感,Spring 4.x 配 MyBatis 3.4 左右是稳妥组合,JDK 用 8,Tomcat 用 8.5 或 9。版本错配是启动失败的头号原因,先把这张表对一遍再动手。
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 工程用了 lambda 和部分 8 的语法 |
| Maven | 3.6+ | 负责依赖下载和 war 打包 |
| Tomcat | 8.5 / 9.0 | 不要用 10,包名已变成 jakarta |
| MySQL | 5.7 / 8.0 | 8.0 需注意驱动类和时区参数 |
| Spring | 4.3.x | 与 MyBatis 整合稳定 |
| MyBatis | 3.4.x | 与 spring-mybatis 1.3.x 配套 |
pom.xml里重点看三块依赖:spring-webmvc、mybatis-spring、mysql-connector-java,以及 druid 连接池。如果本地仓库拉不下来,换一个可用的镜像源,不要手动往 lib 里塞 jar,war 打包时会漏。
4.2 建库与连接配置
数据库脚本一般在src/main/resources/sql/下,先建库再导表:
mysql -u root -p -e "CREATE DATABASE shop DEFAULT CHARSET utf8mb4;" mysql -u root -p shop < src/main/resources/sql/shop.sql然后改jdbc.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码MySQL 8 的驱动类是com.mysql.cj.jdbc.Driver,5.7 用com.mysql.jdbc.Driver;serverTimezone不写会在启动时报时区错误,这是血泪经验里最常见的一条。useSSL=false只是本地开发图省事,生产环境不要照抄。
4.3 启动方式与验证路径
用 IDEA 的话,配一个 Tomcat Server,Deployment 里加 war exploded,Application context 设成/shop,启动后访问http://localhost:8080/shop/login。用命令行则是:
mvn clean package -DskipTests cp target/supermarket.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh启动成功的标志是控制台没有BeanCreationException,访问登录页能看到页面而不是 404 或 500。登录后按“商品列表 → 新增商品 → 入库 → 下单”走一遍,能完整走通说明数据源、事务、视图解析都正常。如果卡在某一步,先看 Tomcat 日志里第一个异常,SSM 的报错往往是连锁的,第一个才是根因。
5. 避坑与排查:那些让课设卡到半夜的问题
5.1 事务不生效,库存扣了流水没写
现象:下单后订单生成了,但库存没减,或者流水表少一条。原因通常是@Transactional加在了 controller 上,或者 service 类没有被 Spring 扫描到,又或者方法内部this.调用导致代理失效。解决:确认注解在 service 实现类的方法上,确认spring-service.xml的扫描包覆盖到实现类,跨方法调用通过注入的接口走代理,不要用this.createOrder()自调用。
5.2 中文乱码,商品名变成问号
现象:页面输入中文,存进数据库变成???。原因有三层:数据库字符集不是 utf8mb4、连接串没带characterEncoding=utf8、web.xml 没配编码过滤器。解决:建库时指定DEFAULT CHARSET utf8mb4,连接串补上编码参数,过滤器设forceEncoding=true,三处缺一不可。改完记得重启 Tomcat,热部署不一定重新加载过滤器。
5.3 静态资源 404,页面没样式
现象:JSP 能打开,但 css、js 全部 404。原因是 DispatcherServlet 映射成/后接管了所有请求,静态资源也被当成 controller 去找。解决:在spring-mvc.xml里加<mvc:resources mapping="/static/**" location="/static/"/>,同时确认web.xml的 url-pattern 是/不是/*。如果用了拦截器做登录校验,记得在拦截器配置里排除/static/**。
5.4 Mapper 绑定失败,启动报 Invalid bound statement
现象:启动时报Invalid bound statement (not found),或者调用时提示找不到 SQL。原因:Mapper XML 的 namespace 和接口全限定名不一致、XML 没放在mapperLocations能扫到的目录、或者方法名和 XML 的 id 对不上。解决:namespace 写成com.shop.mapper.ProductMapper,XML 放resources/mybatis/下,方法名和 id 逐字核对。改完执行mvn clean,target 里的旧 XML 不清掉会继续报错。
5.5 分页参数越界,第二页空白
现象:第一页正常,点第二页列表为空。原因:page 参数从 0 开始传,而 SQL 的 offset 按(page-1)*size算,导致 offset 为负或错位。解决:统一约定 page 从 1 开始,controller 里对 page 做下限保护,page = Math.max(page, 1),并在 PageResult 里算好总页数,前端翻页按钮根据总页数禁用越界页码。
6. 二次开发怎么改:从加一个字段到加一个模块
拿到这种课设工程,最常见的改造需求是“加字段、加模块、换前端”。我的习惯是先跑通再加,改一处验一处,别一次性动五个文件然后对着报错猜。
加字段是最小的改造单元,以商品加“规格”为例,链路是:数据库ALTER TABLE product ADD COLUMN spec VARCHAR(50),实体类加private String spec和 getter/setter,Mapper XML 的 insert 和 select 补上字段,JSP 表单加一个 input,controller 不用动。改完重启,新增一条带规格的商品,再查列表确认能显示,这条链路就闭环了。注意resultType自动映射靠的是列名和属性名一致,列名spec对应属性spec没问题,如果是product_spec就得配别名或开驼峰映射。
加模块的套路更固定:建表 → entity → mapper 接口 + XML → service 接口 + 实现 → controller → JSP。以“会员管理”为例,照着商品模块抄一遍结构,把表名、字段、业务校验换掉即可。真正要动脑的是权限:这个工程的登录拦截器只判断 session 里有没有用户,没有区分角色。要加管理员和收银员两种角色,得在用户表加role字段,拦截器里读角色判断 URL 前缀,比如/admin/**只允许管理员访问。拦截器写法如下:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { Object user = req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login"); return false; } // 角色校验:非管理员访问 /admin/ 开头路径直接拒绝 String uri = req.getRequestURI(); User u = (User) user; if (uri.contains("/admin/") && !"ADMIN".equals(u.getRole())) { resp.sendError(403, "无权限"); return false; } return true; } }注册拦截器时排除登录页和静态资源,否则会死循环重定向。换前端的话,JSP 改成 Thymeleaf 或前后端分离,工作量主要在视图层,controller 返回 JSON 即可,service 和 mapper 基本不动——这也是分层带来的好处,改一层不牵连另一层。
验证改造是否成功,我一般用“三条链路法”:新增一条数据看能不能查出来、改一条数据看列表是否更新、删一条数据看关联表有没有残留。三条都过,再去看异常日志里有没有被吞掉的警告。从那以后我每次接手这类课设工程,都强制先把原始版本完整跑一遍再动手改,因为很多“改出来的 bug”其实是原工程就没跑通。希望这份拆解能帮你少走几个弯路,把这份 SSM 超市管理系统真正用起来。
本文还有配套的精品资源,点击获取