ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM超市管理系统实战:框架整合配置与避坑指南

SSM超市管理系统实战:框架整合配置与避坑指南

简介:这是一份基于SSM(Spring + Spring MVC + MyBatis)框架的超市管理系统课程设计资源,面向计算机专业进行Java Web课程设计、毕业设计或框架入门学习的读者。系统围绕商品管理、库存管理、销售管理、会员管理及统计报表等核心模块展开,后端采用SSM三层架构,前端包含JSP页面及HTML/CSS/JavaScript静态资源,结构完整,适合中初级开发者对照学习SSM整合与业务实现。压缩包共792个文件,容量1.78MB,主要包含Java源码与class字节码、JSP视图、XML配置文件、数据库SQL脚本,以及大量HTML页面和GIF截图,便于直接查看界面效果和运行逻辑。资源梳理了用户、商品、订单、供应商、菜单等实体的Controller与Mapper类,目录层次清晰,可帮助读者快速定位关键代码。目前已有84人学习下载,适合作为课程设计报告撰写和答辩演示的参考素材。

1. SSM超市管理系统:为什么有人两天跑通,有人卡了两周?

SSM(Spring + SpringMVC + MyBatis)超市管理系统是课程设计里出现频率极高的题目,但同样是这套题,交付质量的差距能拉开到几个量级。有人一周内跑通并写完了文档,有人直到答辩前一天还在跟 404 页面搏斗。区别不在业务复杂度——超市系统无非是商品、库存、订单、供应商那点事——而在于你对三个框架协作边界的理解。Spring 管对象生命周期,SpringMVC 管请求进出,MyBatis 管 SQL 与结果集映射,单独看文档都明白,真正卡人的是它们之间的缝隙:配置扫描范围、事务代理机制、静态资源放行、日期序列化规则。

这篇文章的目标很具体:让正在做这个课程设计的人,从建工程到跑通业务,再到答辩前查漏补缺,每一步都有可照做的方案。内容顺序也是常规项目的推进顺序:先把 SSM 的骨架和配置文件立住,再设计数据库表和 Mapper 映射,然后实现分页、登录、JSON 接口三个 Web 层重头戏,最后把最容易踩的坑集中排掉。你不需要提前精通 SSH 或 SSM,照着配置走即可,但读完至少要能说清楚"为什么这样配"。

2. 工程骨架与配置文件:先把SSM三件套的分工立住

2.1 版本选型:课程设计场景选哪组依赖最稳

SSM 的版本组合很现实地影响着你的排错效率。对课程设计来说,我通常建议不要追新,选"资料密度最高"的一套:JDK 8 + Maven 3.6 以上 + Spring 5.0.x + MyBatis 3.4.x + mybatis-spring 2.0.x + PageHelper 5.1.x,MySQL 用 5.7,连接池用 Druid 1.1.x。这套组合在各类技术社区里积累了海量的报错案例和博客模板,你遇到的异常基本都能搜索到现成答案。

有人会问为什么不用 Spring Boot,毕竟 Spring Boot 确实能简化配置。但课程设计的评分表里常常明确写着"体现 SSM 框架整合能力",而且 Spring Boot 的自动配置会把很多关键细节隐藏掉,你回答问题的时候说不清楚 DispatcherServlet 是怎么注册的、SqlSessionFactory 是怎么构建的,反而减分。所以我的态度是:能用 SSM 原始配置就老老实实用,这是练习框架整合能力的好机会。

注意别去碰 Spring 6 / MyBatis 3.5 以上 + JDK 17 的组合。新版框架要求 jakarta 命名空间,很多课程设计用的老模板和参考代码直接跑不了。我不否认新版本在性能上有优势,但对这个场景,稳定可复现才是第一优先级。

2.2 Maven 工程结构与 web.xml:入口配置是第一步

我一般先建一个标准 Maven war 工程,目录结构如下:

supermarket/ ├── pom.xml ├── src/main/java │ └── com/supermarket/ │ ├── controller/ │ ├── service/ │ ├── dao/ │ ├── entity/ │ └── interceptor/ ├── src/main/resources │ ├── spring/ │ │ ├── applicationContext.xml │ │ └── spring-mvc.xml │ ├── mapper/ │ │ ├── ProductMapper.xml │ │ ├── OrderMapper.xml │ │ └── ... │ ├── mybatis-config.xml │ └── jdbc.properties └── src/main/webapp ├── WEB-INF/web.xml ├── static/ │ ├── css/ │ ├── js/ │ └── images/ └── views/

代码分层按 controller、service、dao、entity 四层拆,后面写业务时不会乱。resources 下把 Spring 配置拆成 applicationContext.xml 和 spring-mvc.xml 两份,目的是隔离"根容器"和"Web 容器",这是 SSM 整合的基本盘。

pom.xml 里的核心依赖要精简,别把用不到的包都拉进来。参考片段:

<properties> <spring.version>5.0.15.RELEASE</spring.version> <mybatis.version>3.4.6</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> </dependencies>

spring-jdbc 不是额外的,事务管理器 DataSourceTransactionManager 在它里面,没有它就配不了声明式事务。spring-webmvc 会自动传递依赖 spring-web 和 spring-context,但 spring-jdbc 需要显式声明,这是经常漏掉的一处。

接着是 web.xml,这是 SSM 的入口。这里有两个关键角色:ContextLoaderListener 负责加载根容器,DispatcherServlet 负责加载 SpringMVC 容器。

<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/applicationContext.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/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>

如果你的项目里只有一个 spring-mvc.xml,也可以不配 init-param,让 DispatcherServlet 默认去 /WEB-INF/dispatcher-servlet.xml 找。但课程设计里我建议显式指定 classpath 位置,这样两个配置文件的职责一目了然,答辩时讲起来也顺。

提示:url-pattern 用 "/" 表示拦截所有请求,这也是后面静态资源 404 和 JSON 乱码问题的根源。不要因为暂时没遇到就跳过理解它。

2.3 applicationContext.xml 与 spring-mvc.xml:扫描边界必须划清

继续配置 applicationContext.xml 时,扫描逻辑要小心。根容器管 service、dao 和实体,controller 交给 SpringMVC 容器。这是常见的设计约定,目的是防止同一个 bean 在两个容器里各生成一份,导致事务失效。

一个清爽的写法如下。先看 applicationContext.xml:

<context:component-scan base-package="com.supermarket"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> <context:exclude-filter type="annotation" expression="org.springframework.web.bind.annotation.RestController"/> </context:component-scan>

再看 spring-mvc.xml:

<context:component-scan base-package="com.supermarket.controller" use-default-filters="false"> <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/> <context:include-filter type="annotation" expression="org.springframework.web.bind.annotation.RestController"/> </context:component-scan>

根容器扫描 com.supermarket 全局再排除 Controller,子容器只扫 controller 包并只接收 Controller 注解。这样 service、dao 只会被根容器管理一次,SpringMVC 容器只管分发请求的 Controller。

说完扫描,顺便把 mybatis-config.xml 和 mapper 配置补上。mybatis-config.xml 里建议只放全局设置,比如下划线转驼峰和 PageHelper 插件:

<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> <plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> </plugin> </plugins> </configuration>

mapUnderscoreToCamelCase 设为 true 后,数据库字段 order_no 可以自动映射到实体属性 orderNo,省去大量 resultMap 编写。这个是课程设计里性价比最高的配置项,谁用谁知道。

2.4 数据源与事务管理器:声明式事务的最后一块拼图

数据源、SqlSessionFactory、Mapper 扫描、事务管理器这四样通常在 applicationContext.xml 里统一配置。我是这么写的:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/supermarket?useUnicode=true&amp;characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="root"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configLocation" value="classpath:mybatis-config.xml"/> <property name="typeAliasesPackage" value="com.supermarket.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.supermarket.dao"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

mapperLocations 指定 XML 文件路径,typeAliasesPackage 让实体类在 Mapper XML 里可以直接写 Product 而不用写全限定名。MapperScannerConfigurer 则自动为 dao 包下每个接口生成代理实现,不需要自己写实现类。

事务管理器绑定同一个 dataSource,再通过 tx:annotation-driven 开启 @Transactional 注解驱动。这一条配置的失败率极高,后面第 5 章会细说。现在你只需要记住一个原则:SqlSessionFactory、MapperScannerConfigurer、DataSourceTransactionManager 这三个 Bean 的 dataSource 必须是同一个对象,这里漏了任何一个引用不一致,都会出现"连接断开"或"事务不生效"的诡异问题。

3. 数据库设计与MyBatis映射:商品、库存、订单六张核心表先立住

3.1 六张核心表:字段、索引与关系设计

超市系统的业务可以浓缩为三个动作:进货、销售、统计。围绕它,我一般会建六张表:商品表 product、商品分类表 category、供应商表 supplier、用户表 user、订单表 orders、订单明细表 order_item。这样的粒度刚好够演示关联查询和事务,也不会复杂到课程设计写不完。

表结构参考如下:

表名关键字段说明
productid, name, category_id, supplier_id, barcode, price, stock, status, create_time商品主表,category_id 和 supplier_id 分别关联分类与供应商
categoryid, name商品分类,建议预置“饮料、零食、日用品”等数据
supplierid, name, contact, phone供应商,进货单时选择
userid, username, password, role员工账号,role 区分管理员与收银员
ordersid, order_no, user_id, total_amount, status, create_time销售订单主表,order_no 唯一
order_itemid, order_id, product_id, price, quantity订单明细,冗余商品单价

字段类型上,价格用 decimal(10,2) 而不是 float,避免精度问题;库存用 int;order_no 用 varchar(32) 存业务单号;create_time 用 datetime。主键全部是 bigint 自增,但订单表里不要依赖主键对外暴露,业务上只用 order_no。

外键这一项我建议不建物理外键,只保留逻辑关联。原因很实际:课程设计的删除操作通常要求"分类下没商品才能删分类"这类校验,用代码控制比数据库级联更直观,也方便你在项目文档里多写一段业务逻辑。但查询必须要走关联,所以 category_id、supplier_id 这两个字段必须建普通索引:

ALTER TABLE product ADD INDEX idx_category (category_id); ALTER TABLE product ADD INDEX idx_supplier (supplier_id); ALTER TABLE orders ADD INDEX idx_order_no (order_no);

索引不要加太多,课程设计的数据量远远到不了需要调优的程度,但字段上没索引会导致 MySQL 全表扫描,哪怕数据量小,执行计划解释起来也不好看。三四个索引足够。

3.2 MyBatis 映射文件:复用生成器还是手写 SQL

很多同学喜欢用 MyBatis Generator 一键生成实体和 Mapper XML。我的建议是:生成器可以出实体类和 DAO 接口,但 Mapper XML 里的业务 SQL 不要全靠它。生成器生成的 CRUD 是固定套路,一旦你后面加个关联查询、条件拼 SQL,它帮不上忙,而且重新生成时容易覆盖手写代码。

我更推荐手写 Mapper XML,或者只把生成器当起点。理由是我需要每个方法的 SQL 都烂熟于心,答辩时老师随便指一个方法问你"这条 SQL 为什么这样写",你不至于对着生成代码说不出所以然。

下面这个商品分页查询是最典型的写法,直接用动态 SQL 拼多条件:

<select id="selectProductPage" resultType="Product"> SELECT p.id, p.name, p.price, p.stock, p.status, c.name AS categoryName, s.name AS supplierName FROM product p LEFT JOIN category c ON p.category_id = c.id LEFT JOIN supplier s ON p.supplier_id = s.id <where> <if test="name != null and name != ''"> AND p.name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="status != null"> AND p.status = #{status} </if> </where> ORDER BY p.id DESC </select>

标签会自动处理掉第一个条件前面的 AND,这是 MyBatis 动态 SQL 里最实用的容错机制。参数占位必须用 #{},它底层走 PreparedStatement 参数绑定,既防 SQL 注入,又帮你处理字符串引号;${} 是直接拼接 SQL,只有在排序字段等动态列名场景才考虑用,而且绝不能接收用户直接输入。课程设计里经常有同学图省事在 like 查询里写 ${name},这就是给答辩埋雷。

3.3 订单号生成与库存扣减:事务边界必须落在 Service

订单业务是超市系统的核心,也是事务最好的演示场景。一个创建订单的流程包含三步:插入订单主表、逐条插入订单明细、扣减商品库存。三步要么全成功,要么全回滚,这就是 @Transactional 存在的意义。

我习惯把事务边界放在 Service 接口实现类的 public 方法上:

@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateDTO dto) { String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setTotalAmount(calcTotalAmount(dto.getItems())); order.setStatus(0); orderMapper.insert(order); for (OrderItemDTO item : dto.getItems()) { int rows = productMapper.decreaseStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("商品库存不足或商品不存在"); } OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setProductId(item.getProductId()); orderItem.setPrice(item.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); } return order.getId(); }

generateOrderNo 我建议用"yyyyMMddHHmmss + 4 位随机数"的组合,单机并发下通常够用。decreaseStock 对应的 SQL 必须写成原子扣减,这个细节太重要,单独放到第 5 章讲,你先把"扣减失败要抛异常触发回滚"这个思路记住。

rollbackFor = Exception.class 一定要写。不写的话,Spring 默认只对 RuntimeException 回滚,检查异常默认不回滚。课程设计里最容易出现的场景是业务校验失败时抛了一个自定义 checked exception,结果数据照写不误,这就是没配 rollbackFor 的经典翻车。事务加在 Controller 方法上是无效的,Spring AOP 代理默认不拦截 Controller,最稳妥的做法就是放在 Service 实现类方法上,并且通过外部调用进入,而不是同类里 this.createOrder 这种自调用。

4. 从 DAO 到 Controller:分页查询、登录拦截与 JSON 日期模型

4.1 商品分页查询:集成 PageHelper 并保持查询条件干净

超市管理系统的商品列表不可能一页展示完,所以分页是刚需。实现分页有两种思路:手写 limit 参数,或者接入 PageHelper 插件。课程设计里我强烈建议用 PageHelper,理由不是它省事,而是它让你能把精力放在查询条件组织上,而不是处理"第几页、每页几条、总记录数"这些重复代码。

PageHelper 的用法非常固定:在 MyBatis 查询前调用 PageHelper.startPage,紧接着执行查询,然后用 PageInfo 包装结果。

public PageResult<ProductVO> pageProduct(int pageNum, int pageSize, String name, Long categoryId) { PageHelper.startPage(pageNum, pageSize); List<ProductVO> list = productDao.selectProductPage(name, categoryId); PageInfo<ProductVO> pageInfo = new PageInfo<>(list); PageResult<ProductVO> result = new PageResult<>(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }

Controller 层接收参数后直接调用,返回统一结果体:

@GetMapping("/product/list") @ResponseBody public Result<PageResult<ProductVO>> list( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String name, @RequestParam(required = false) Long categoryId) { PageResult<ProductVO> data = productService.pageProduct(pageNum, pageSize, name, categoryId); return Result.ok(data); }

PageHelper 的关键约束是 startPage 后面必须紧跟第一次真正执行的 MyBatis 查询,中间不能夹带其他查询语句或业务处理,否则分页会拦截到错误的 SQL。另外一个常见错误是在循环里反复调用 startPage,这会导致分页状态串到下一次查询上。如果代码逻辑必须分支,我一般会在分支内部各自执行 startPage 和查询,而不是在方法开头统一 startPage。

注意:PageHelper 5.x 版本里 startPage 只对其后的第一条 select 语句生效。如果你在前后之间插入了一条 update 或另一条无关查询,分页可能失效,这是排查分页异常时最先要看的位置。

4.2 登录拦截器:区分未登录用户和管理员会话

超市系统要区分管理员和收银员权限,最常规的做法是基于 Session 的登录拦截,而不是上 Spring Security。课程设计用 Security 太重了,配置繁琐且不好讲,一个 HandlerInterceptor 足以把需求说清楚。

拦截器实现参考:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }

在 spring-mvc.xml 中注册时要放行登录相关的路径和静态资源:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/doLogin"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/css/**"/> <mvc:exclude-mapping path="/js/**"/> <mvc:exclude-mapping path="/images/**"/> </mvc:interceptor> </mvc:interceptors>

拦截器不要拦截 /login 和 /doLogin,不然用户永远进不了系统。这个配置必须放在 spring-mvc.xml 里,因为它是 SpringMVC 容器管理的组件,放 applicationContext.xml 里 DispatcherServlet 不认。

如果需要区分管理员与收银员,可以在登录时把 role 写入 Session,preHandle 里再校验一次前缀路径。例如 /admin/ 开头的请求要求 role 为 1,否则返回 403。

4.3 统一返回体与 JSON 日期序列化:前端拿到的数据格式要稳定

Controller 的返回值如果直接返回裸的 List 或 Map,前端处理起来很混乱。我的习惯是包一层 Result 统一返回体,包含 code、msg、data 三个字段,成功时 code 为 200,失败时返回业务错误码和提示信息。这样前端只需要解析一个固定结构,遇到登录超时也能统一跳转。

统一返回体的代码很简单:

public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> error(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } // getter/setter 省略 }

JSON 序列化由 Jackson 自动完成,但日期字段默认输出成时间戳,也就是一串毫秒数,前端拿到根本没法直接展示。解决思路两个:要么在实体日期字段上写 @JsonFormat,要么全局配置 ObjectMapper。课程设计我更推荐用注解,直观且不需要额外配置:

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private Date createTime;

timezone 必须写成 GMT+8,否则在东八区环境下 Jackson 可能按默认时区输出,时间差 8 个小时,查单的时候对不上这是很常见的诡异 bug。如果你担心每个日期字段都加注解太啰嗦,就去定义全局 Jackson 配置,但注解方案在答辩时更容易当场说明白,所以我默认用它。

5. 避坑手册:SSM 超市系统最常见的 5 个翻车点与排查路径

5.1 静态资源全部 404:DispatcherServlet 把 CSS/JS 拦走了

现象:登录页面样式全没了,浏览器控制台一堆 "Failed to load resource: the server responded with a status of 404",css、js、图片全拿不到。

原因:web.xml 里 DispatcherServlet 的 url-pattern 配成了 "/",它拦截了所有请求,也包括静态资源。SpringMVC 容器里找不到处理 .css 的 Handler,直接返回 404。

解决:在 spring-mvc.xml 里配置资源映射:

<mvc:resources mapping="/static/**" location="/static/"/> <mvc:default-servlet-handler/>

mvc:resources 把 /static/** 路径映射到 webapp/static 目录,default-servlet-handler 让无法处理的请求回退到容器默认 Servlet。这一步配置完,CSS 和 JS 就能正常加载了。排查时先确认你的页面引用路径是 /static/css/style.css 还是 /css/style.css,两套映射规则不一样,路径对不上也会 404。

5.2 事务悄悄失效:Service 方法异常了数据还在写

现象:createOrder 里明明在库存扣减不足时抛了 RuntimeException,但订单主表数据还是插入成功了,库存也没扣。

原因:三个常见诱因。第一,@Transactional 写在了 Controller 方法上,而 Controller 默认不参与事务代理。第二,事务注解在私有方法上,代理无法拦截 private 方法。第三,同一个 Service 内部方法自调用,比如 createOrder 里调用 this.updateStock(),这属于 this 调用,绕过了代理对象,事务注解变成装饰品。

解决:把 @Transactional 放在 Service 实现类的 public 方法上,确保方法修饰符为 public,并且通过外部调用链路进入。同一类内部不要自调用,如果一定要抽公共逻辑,放到另一个 Service 里注入使用。排查事务问题时,可以在日志里打印当前连接是否开启了事务,或者观察异常回滚后数据库数据是否残留。先检查方法修饰符,再检查调用链,最后检查配置里的 tx:annotation-driven 是否真的在根容器生效。

5.3 MyBatis 报 Invalid bound statement:Mapper XML 压根没被加载

现象:项目启动没问题,Controller 调 service 时抛org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.supermarket.dao.ProductDao.selectProductPage。

原因:Mapper 接口存在,但对应的 XML 没有注册到 SqlSessionFactory。常见情况是 mapperLocations 配成了classpath:mapper/*.xml,但 XML 文件放在了 src/main/java 目录下,Maven 默认不把 java 目录下的 xml 打进 classes,运行时加载不到。

解决:最干脆的做法是把 mapper XML 统一放到 src/main/resources/mapper 目录下,然后确认 mapperLocations 路径一致。如果你的项目必须把 XML 放 java 包里,在 pom.xml 加 build 资源配置,但我不推荐,因为这对课程设计来说毫无必要,只会徒增困扰。

另外检查 XML 文件里的 namespace 是不是接口全限定名。namespace 写成 com.supermarket.dao.ProductDao,接口才能和 XML 对应上。这里报错的排查路径很固定:一看 target/classes 下有没有 XML,二看 namespace 和方法 id 是否匹配。

5.4 商品库存超卖:前端限购挡不住并发

现象:两个人同时下单,商品库存只剩 10 件,结果两个订单都成功,库存变成 9 或者变成负值。

原因:典型的 check-then-act 竞态。很多实现是先 SELECT 库存,判断库存大于下单数量,再 UPDATE 扣减。两条线程同时读到库存 10,都判断可以下单,都去 UPDATE,最后只扣了一次甚至不扣。

解决:把判断和扣减合并成一条原子 SQL:

<update id="decreaseStock"> UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity} </update>

这条 SQL 让数据库行锁保证原子性,只有在当前库存大于等于购买数量时才扣减,影响行数为 0 表示库存不足。Service 里通过 int rows 判断失败并抛异常,触发事务回滚,订单就不会生成。这是超市系统里最值得强调的一个设计,答辩时老师也大概率会往并发方向追问,你能答出"原子更新 + 受影响行数判断"这一层就算稳了。

5.5 日期字段变成一串数字:Jackson 时间戳问题

现象:前端在订单列表里看到 createTime 显示为 "1690000000000" 这样的毫秒时间戳,没法直接阅读。

原因:SpringMVC 默认用的 Jackson 会把 java.util.Date 序列化成时间戳。这是 Jackson 的默认行为,不是 bug,但不满足业务展示需求。

解决:三个办法选一个。字段上加 @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8"),或者在 applicationContext 里全局配置 ObjectMapper:objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL),同时设置日期格式。如果你用了 fastjson,那要换一套注解。课程设计里我不建议混用 JSON 库,SSM 默认带的是 Jackson,就一路用 Jackson 到底,别为了省事引入其他库增加排错变量。

6. 从「能跑」到「能答辩」:验证清单、两个加分项和一个习惯

系统跑通只是开始,答辩才是终点。我的习惯是动态维护一份验证清单,上线答辩前按清单逐项过一遍,不凭感觉。清单不长,但覆盖核心链路:

登录登出是否正常拦截未登录访问;商品增删改查是否完整,价格和库存字段有没有更新到位;进货流程是否让库存增加并写入进货记录;前台收银创建订单后,库存是否准确扣减,订单总额是否计算正确;分页在第二页、空数据、搜索关键字无结果三种情况下是否正常;部门权限隔离,收银员有没有办法访问管理员接口。

把这些步骤截图整理到项目报告里,比贴十页代码有用得多。

第一个加分项是登录操作日志。用一个拦截器或者 AOP 切面,在 Controller 方法执行前记录操作人、操作时间、请求路径、参数摘要。课程设计只要把这个功能做出来,老师会认为你考虑了系统的可审计性,这是真实项目里很重要的非功能需求。实现上不用复杂,一个自定义注解加一个 Spring AOP 切面即可,十行代码的复杂度就能讲出一个漂亮的设计点。

第二个加分项是收银台页面的简单统计卡片。用 ECharts 画一个当日销售额折线或分类占比饼图,数据来源是订单明细表的聚合查询。这条不加也不影响功能完整性,但加了之后,整个系统的完整度会明显高一个档次,而且统计查询正好覆盖了 MySQL 的 GROUP BY 和聚合函数,让数据库设计部分更有说服力。

最后一个习惯:每写完一个接口,就在项目笔记里记一行"这个接口是给哪个页面用的、参数有哪些、返回结构是什么"。我当年做这类课程设计时吃过亏,写的时候很顺,三天后再看自己代码要想半天。有了运行笔记,整理报告和准备答辩会快很多,也减少答辩时被问到细节却答不上来的尴尬。希望这个方法能帮你在 SSM 超市管理系统上少走弯路,把精力留给真正该思考的业务逻辑和后端设计。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进