SSM框架整合实战:从零搭建超市管理系统全流程解析

发布时间:2026/10/11 20:21:52
SSM框架整合实战:从零搭建超市管理系统全流程解析
最近刚完成一个基于 Java SSMSpring SpringMVC MyBatis的超市管理系统开发周期两周左右数据库用的 MySQL前端用的是传统的 JSP JQuery。这类项目在课程设计、毕业设计里非常常见网上虽然能搜到不少源码但很多都缺配置说明照着跑起来往往一堆报错。所以我决定把这次从零搭建的过程完整记录下来包括功能规划、数据库设计、SSM 整合细节、商品管理模块的完整代码以及我踩过的那些坑。无论你是刚学完 JavaWeb 准备做项目练手还是正在找 Java 课程设计源码参考这篇文章都能让你少走不少弯路。1. 项目整体设计与思路拆解1.1 为什么选 SSM这个组合到底好在哪先说结论SSM 是 JavaWeb 阶段一个非常有价值的轻量级组合。Spring 负责对象管理和事务SpringMVC 负责接收请求和返回页面MyBatis 负责把 Java 对象和数据库表映射起来。三者的分工非常明确就像一个小公司里的前台、行政和人资各司其职。对于一个超市管理系统业务无非是商品、库存、销售、往来单位的管理流程并不复杂用这个组合再合适不过。有人可能会问为什么不直接用 Spring Boot这个问题我在做之前也纠结过。Spring Boot 确实配置更省但它的很多自动装配细节都被隐藏了出了问题很难排查。而 SSM 所有配置都要手动写一遍Spring 容器什么时候创建、DispatcherServlet 如何分发、SqlSessionFactory 怎么被注入这些概念都能在动手的过程中真正落地。做完这个项目再回头用 Spring Boot你会发现“降维打击”一样轻松。对于以学习为目的的课程设计或入门项目选 SSM 反而更有锻炼价值。1.2 角色与功能模块先画清楚边界再写代码动手之前最重要的一件事是别急着写代码先把“谁在用这个系统”和“要管什么数据”想清楚。我按超市场景把用户分成三类系统管理员负责员工账号、供应商资料、系统参数维护仓库管理员负责商品录入、进货入库、库存调整收银员负责前台收银、销售出库、退货处理。这三类角色的业务边界有重叠比如管理员也可以查看销售统计但核心功能最好分开。模块上我划分成八个大块登录认证、商品管理、分类管理、供应商管理、进货管理、销售管理、库存查询、销售统计。每种角色登录后只看到自己有权限的菜单这样即使没有引入复杂的 RBAC 框架也能保证基本的数据隔离。这里有一点建议如果你是在做毕业设计可以在菜单里增加“库存预警”和“到期商品提醒”。这两个功能实现起来并不难但会让系统看起来更完整答辩时也更容易解释。1.3 数据库表设计这些字段千万别省超市管理系统最大的坑不是逻辑复杂而是表设计太潦草。我第一版表结构只有商品表、用户表、订单表后来发现退货、多商品订单、进货批次统计全都无从下手。最终我采用的是“主表 明细表”的结构这是所有进销存系统的基本盘。核心表包括数据表关键字段作用sys_userid、username、password、role_id、status登录账户角色关联goods_categoryid、name、sort商品分类supplierid、name、contact、phone、address供应商goodsid、category_id、supplier_id、name、model、price、stock、status、create_time商品主信息purchase_orderid、supplier_id、order_no、total_amount、create_time进货单主表purchase_itemid、purchase_id、goods_id、quantity、price、subtotal进货明细sale_orderid、order_no、user_id、total_amount、pay_time销售单主表sale_itemid、sale_id、goods_id、quantity、price、subtotal销售明细goods 表中的 stock 字段是冗余字段每个商品的当前库存直接挂在商品表上。明细分表中只记录进货和销售的数量最终库存由“累计进货 - 累计销售”推导。理论上库存可以完全通过汇总计算得出但超市收银场景每次查询都要 SUM 会很慢所以保留 stock 字段同时用事务保证每次销售都同步扣减。这样做的代价是可能出现库存不一致所以后期要定期做库存盘点用盘点单修正差值。在创建表的时候所有金额字段我都用了 decimal(10,2) 而不是 double。float/double 在涉及金额累加时会产生精度误差这是常识但在课程设计里很多人会犯。订单号也不要自增建议用一个时间戳字符串比如 yyyyMMddHHmmss 随机数方便后续对接小票打印也避免订单号泄露业务量。2. 核心细节解析与实操要点2.1 Spring IoC 与事务从一次“超卖”说起超市收银看起来简单但落到代码里最关键的是要保证“扣库存”和“加销售单”在同一事务中完成。假设当前库存只有 3 件两台收银机同时卖出同一件商品如果没有事务就可能出现俩订单都成功、库存变成负数的结果。同类问题在电商里叫“超卖”超市系统虽然并发没电商高但同一数据被并发更新的风险依然存在。我会在 service 层的销售方法上直接加Transactional例如Transactional(rollbackFor Exception.class) public SaleOrder createSale(SaleOrder order, ListSaleItem items) { // 1.校验并扣减库存 // 2.保存销售主单 // 3.保存销售明细 // 4.如果任一步骤异常全部回滚 }默认情况下 Spring 事务只对 RuntimeException 回滚如果方法里抛出了 checked exception比如 IOException事务并不会回滚。最稳妥的方式是像上面这样显式写rollbackFor Exception.class把一切异常都交给事务处理。业务方法内部也不要随手 try-catch否则异常被吞掉事务边界感知不到数据照样写进去。还有一个很多初学者忽略的点事务传播级别。同一个 Controller 调用了不同 Service 的方法如果每个方法各自加了事务默认REQUIRED传播可以保证它们都参与到第一个事务里但如果其中一个方法加的是REQUIRES_NEW即使外部事务回滚它也可能已经提交。这里建议先用默认传播不要炫技。2.2 SpringMVC 请求流转从 URL 到返回数据的全过程商品管理里最常见的请求就是/goods/list?page1limit10这样的分页查询。请求进来后DispatcherServlet 会先找到 HandlerMapping匹配到GoodsController里对应的方法然后 SpringMVC 通过参数解析器把 URL 上的 page、limit 自动绑定到方法参数上。这一步看似简单但它依赖方法参数的名称如果在编译时没有加-parameters参数IDE 里用 DEBUG 模式打出的参数名可能是 arg0。开发时直接改 Tomcat 的 JVM 参数不太现实最简单的做法是给参数写清楚RequestParam(page) Integer page避免依赖参数名推断。超市系统里有大量增删改查请求我习惯把接口风格统一为 REST 风格查询用 GET新增和修改用 POST删除用 POST 或 GET。不会刻意追求DeleteMapping因为传统 JSP 页面直接发表单提交更方便。返回类型上有两种选择查询列表返回ModelAndView渲染 JSP新增/编辑操作返回重定向redirect:/goods/list这样可以避免刷新页面时重复提交表单。这种“PRG 模式”在表单提交场景里很实用。2.3 MyBatis 动态 SQL一句 SQL 搞定多条件查询超市的查询条件非常多商品名称模糊、分类、价格区间、库存阈值这些条件用户可能填一部分。MyBatis 的动态 SQL 就是为这种场景设计的我用where加多个if来拼条件例如select idfindByCondition resultTypecom.example.entity.Goods select * from goods where if testname ! null and name ! and name like concat(%, #{name}, %) /if if testcategoryId ! null and category_id #{categoryId} /if if testminPrice ! null and price gt; #{minPrice} /if if testmaxPrice ! null and price lt; #{maxPrice} /if /where order by id desc /select注意大于小于号要转义成gt;和lt;不然 XML 解析直接报错。所有条件参数都用#{}占位不要用${}后者等价于字符串拼接存在 SQL 注入风险。如果某天需要动态排序才用${}传排序字段前提是字段名必须来自白名单不能直接暴露给用户。我在这套系统里没有大量使用 resultMap因为大多数查询字段与表字段同名开启 MyBatis 的驼峰映射后表字段create_time能自动映射到实体createTime。只有在联查商品分类名、供应商名时才用了 resultMap或者更省事一点直接在 SQL 里写别名然后用 resultType 接收一个轻量 VO。对于课程设计用 VO 会更简单不用在 XML 里堆一堆 association。2.4 登录与权限控制拦截器比过滤器更适合页面级控制SSM 项目做登录校验很多教材推荐 Filter但我实际用下来更喜欢 SpringMVC 的拦截器。拦截器能拿到完整的 HandlerMethod可以精确判断请求的是不是某个 Controller 方法还能获取注解做更细粒度的权限控制。对超市系统来说登录会话校验用拦截器已经足够preHandle()里取 session如果用户为 null 就重定向到/login页面否则放行。菜单权限我是通过用户表里的 role_id 做的。管理员登录后可以看“员工管理”“供应商管理”收银员看不到这些菜单只能看到“前台收银”。JSP 渲染时用c:if test${sessionScope.user.roleId 1}来控制菜单显示。这种简单方案虽然做不到按钮级权限但满足超市管理场景足够。要提醒的是不要把密码明文存在数据库里。课程设计常用的做法是 MD5 加密但 MD5 已经不安全至少也要在 MD5 后加盐。我这次用了 hutool 的 MD5 工具类加固定盐或者你也可以引入 Spring Security Crypto 里的 BCryptPasswordEncoder一行代码就能生成不可逆的哈希。如果选 MD5一定要在项目说明文档里写明“仅作演示用途”答辩时被问到就说自己清楚更安全的方案印象分会好很多。3. 实操过程与核心环节实现3.1 环境版本与项目依赖老规矩先列环境。JDK 8、Maven 3.6.3、Tomcat 8.5、MySQL 5.7、IDEA 2019.3。之所以不推荐更高版本是因为这套 SSM 组合在 JDK 8 上的资料最全踩坑容易搜。创建 Maven 项目时打包方式选 war坐标按自己域名写。pom.xml 核心依赖如下properties spring.version5.2.5.RELEASE/spring.version /properties dependencies !-- Spring -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency !-- 数据库连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.5/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency !-- 其他jstl、jackson、servlet-api等 -- /dependencies注意 mysql-connector-java 的版本要和 MySQL 版本匹配5.1.49 对应 MySQL 5.7 没问题。如果用 MySQL 8需要换成 8.0.x同时 URL 里的驱动名要改为com.mysql.cj.jdbc.Driver并加上时区参数否则启动会报时区错误。3.2 SSM 三大配置文件整合配置是整个项目最劝退人的地方我把它拆成两个 Spring 容器配置文件加一个 MyBatis 配置。Spring 容器配置一般叫applicationContext.xml放在src/main/resources下主要内容是数据源、SqlSessionFactory、Mapper 扫描、事务管理。?xml version1.0 encodingUTF-8? beans ... context:component-scan base-packagecom.supermarket context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/supermarket?characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameconfigLocation valueclasspath:mybatis-config.xml/ /bean mapper:scan base-packagecom.supermarket.mapper/ bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/ /beans注意context:component-scan中排除了Controller把 Controller 留给spring-mvc.xml管理。如果不排除会出现事务失效、AOP 增强不生效等诡异问题后面我会专门讲。spring-mvc.xml里负责扫描 Controller、配置视图解析器、静态资源和拦截器。视图解析器我记得要配前缀/WEB-INF/jsp/后缀.jsp这样 JSP 页面放在 WEB-INF 下用户不能直接通过 URL 访问必须走 Controller对系统安全性是个提升。web.xml中需要配置DispatcherServlet、ContextLoaderListener和编码过滤器。编码过滤器一定要放在最前面过滤器顺序决定了它能否先处理请求编码。3.3 商品管理模块代码从表结构到页面一把梭我选商品管理模块作为示例因为它是整个系统的地基。实体类比较常规核心是 Mapper。GoodsMapper.java接口定义两个方法一个是带条件的分页查询findPage(Map params)一个是统计总数count(Map params)这两个配合实现分页。public interface GoodsMapper { ListGoods findPage(MapString, Object params); Long count(MapString, Object params); int insert(Goods goods); int update(Goods goods); int deleteById(Integer id); }对应GoodsMapper.xml里分页 SQL 的limit起始行需要在 Java 里算好传入参数中写start (page - 1) * limit然后 SQL 里写limit #{start}, #{limit}。Service 层最重要的是事务处理例如新增商品时同时把分类和供应商信息关联进去。Controller 是前端和后端的桥梁我习惯在方法上直接返回 String视图路径数据放到 Model 里。例如GetMapping(/goods/list) public String list(RequestParam(value page, defaultValue 1) Integer page, RequestParam(value limit, defaultValue 10) Integer limit, Goods goods, Model model) { MapString, Object params new HashMap(); params.put(start, (page - 1) * limit); params.put(limit, limit); params.put(goods, goods); ListGoods list goodsService.findByPage(params); Long total goodsService.count(params); model.addAttribute(list, list); model.addAttribute(total, total); return goods/list; }页面端我用 JSTL 做循环和分页展示。分页控件不用写太复杂上一页和下一页加页码即可。业务重点在后面的查询统计上。3.4 登录拦截器与密码加密实现登录流程不复杂提交表单后查询user表比对用户名、密码加密后如果正确就写入 session跳转到首页。如果错误就返回login.jsp并带出错误提示。这里有一个细节用户名要查出来再比对密码不要用 SQL 把密码也拼进去否则数据库一样的数据无法区分是密码错误还是用户不存在而且建议在代码里统一返回“用户名或密码错误”避免被撞库探测用户名。拦截器代码public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); if (session.getAttribute(loginUser) ! null) { return true; } response.sendRedirect(request.getContextPath() /login); return false; } }在spring-mvc.xml里配置拦截路径不要拦截静态资源和 login 请求mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/images/**/ mvc:exclude-mapping path/favicon.ico/ bean classcom.supermarket.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptors这里最容易出错的是项目部署后静态资源全部被拦截器挡住。配置mvc:default-servlet-handler或排除静态路径都可我两种都配了稳妥。3.5 销量统计报表怎么做一条 SQL 加一个 ECharts作为超市管理系统销售统计是答辩时的加分项。最简单实用的做法是在sale_order表上按日期聚合SQL 可以写成SELECT DATE(create_time) AS createDate, COUNT(*) AS orderCount, SUM(total_amount) AS totalAmount FROM sale_order WHERE create_time BETWEEN #{startDate} AND #{endDate} GROUP BY DATE(create_time) ORDER BY createDate;Controller 通过ResponseBody返回ListMapString, Object前端页面用 jQuery 的 ajax 请求这个接口再把数据填充到 ECharts 柱状图中。前端不用重新切换整个页面体验比单纯 JSP 输出表格好很多。这里要注意的坑是ECharts 是纯前端图标库必须通过 CDN 或本地静态资源引入。有些课程设计为了省事直接把图标库下载下来放类路径下但没在spring-mvc.xml里映射静态资源导致页面图标一直加载不出来。我的经验是把静态资源统一放在webapp/static下然后配置resources mapping。4. 常见问题与排查技巧实录4.1 项目启动后 404先查这里别慌SSM 项目 404 的排查顺序我一般固定为先看 Tomcat 控制台有没有报错如果有就翻 Caused by没有报错就看浏览器地址确认应用上下文路径是否包含在访问 URL 中。因为 IDEA 部署时的默认 Application context 往往是/ssm_supermarket_war_exploded/如果习惯开发时不带项目名访问就会 404。另一个高频问题是web.xml里 DispatcherServlet 的 url-pattern。如果配的是*.doController 的RequestMapping必须以.do结尾如果配成/则走 REST 风格。课程设计里两种都常见自己心里要有数。最后再检查 JSP 文件的物理路径视图解析器前缀后缀拼出来必须真实存在。4.2 Invalid bound statement99% 是这几个原因MyBatis 最经典的报错就是Invalid bound statement (not found)。出现这个报错时代码在 IDE 里看上去没问题接口和 XML 都在但运行时就是找不到。原因基本集中在XML 文件的 namespace 没有写成 Mapper 接口的全限定名mapper-locations没加载到 XML比如路径写成classpath:mapper/*.xml但 XML 在java目录下打包后不在 classes 里Mapper 接口被扫描了但 Spring 容器里没有注入service 里Autowired失败XML 里 statement 的 id 和接口方法名不一致。我的排查方法是先在 MyBatis 源码里加断点看MapperRegistry里的方法但这对新手太麻烦。更快的办法是反编译 classes 目录确认 XML 是否真的输出了没有输出就去 pom.xml 的 build 里加resources资源配置。这个方法很笨但非常有效。4.3 事务不生效的隐形坑事务不生效分为两种一种是数据库表引擎是 MyISAM它本身不支持事务执行UPDATE后即使报错也不会回滚另一种是 Spring 层面的问题。MyISAM 的坑防不胜防建表时务必用ENGINEInnoDB。Spring 层面常见的坑是同类内部调用同一个 Service 类里方法 A 没有事务调用方法 B 加了TransactionalB 上事务不会生效。因为 Spring 的代理是在调用入口处生效内部this.method()是直接调原始对象没有经过代理。遇到这种情况可以把 B 挪到另一个 Service或者用TransactionTemplate手动控制。还有如果异常被 try-catch 吞掉了事务同样不生效。排查时把 catch 块里e.printStackTrace()改成直接抛出RuntimeException或者使用全局异常处理器统一记录。4.4 中文乱码问题汇总中文乱码这块我列个速查表遇到直接对号入座乱码现象可能原因解决办法JSP 页面中文显示为问号JSP 编码设置不对% page contentTypetext/html;charsetUTF-8 %表单提交到后台后乱码POST 请求未编码web.xml配置CharacterEncodingFilter并且要放在第一个过滤器数据库查询结果乱码连接 URL 没带字符集URL 增加characterEncodingutf8写入数据库后数据乱码表字符集不是 utf8建表语句DEFAULT CHARSETutf8mb4ajax 拿到 JSON 中文乱码SpringMVC 消息转换器编码不对在 spring-mvc.xml 中配置 StringHttpMessageConverter 为 UTF-8其中“编码过滤器必须放在最前”这个点最容易被忽略。Tomcat 容器里的过滤器是一个链如果其他过滤器先处理了请求后面编码过滤器再去设置 request 编码可能已经来不及。4.5 分页查询老出错手写分页还是要 PageHelper超市系统的列表页都有分页。最简单的实现是手写limit start, limit但这种方案每次都要在 Service 里计算 start代码重复感很强。我这次用了一次 PageHelper 插件引入依赖后在查询前调用PageHelper.startPage(page, limit)紧接着的查询会自动拼接limit然后返回PageInfo就能拿到总数和列表。核心代码就两行PageHelper.startPage(page, limit); PageInfoGoods pageInfo new PageInfo(goodsMapper.findPage(params));配置 PageHelper 也有坑版本要与 MyBatis 兼容。我用的是com.github.pagehelper:pagehelper:5.2.0对应 MyBatis 3.5.x 没问题。如果是在 SSM 中配置只需要给 SqlSessionFactory 的 configLocation 添加plugin interceptorcom.github.pagehelper.PageInterceptor/即可不要在 applicationContext 里重复初始化插件否则会出现“分页不生效但也不报错”的奇怪现象。还要牢记 PageHelper 是“只对紧接着的一条 SQL 生效”。如果 startPage 后面执行了插入或者其他查询分页就会作用到那条 SQL 上导致列表数据不对。项目里我建议把startPage和selectList写在同一个方法里中间不要插其他数据库操作。4.6 部署上线时容易踩的配置坑如果要把项目打包成 war 部署到独立 Tomcat注意三个地方一是 pom.xml 的finalName要和你希望的应用名一致比如supermarket.war二是数据库密码不要硬编码在 xml 里可以使用 JNDI 数据源或者放到 properties 文件并排除到 war 外三是log4j.properties里的日志级别不要配成 DEBUG否则控制台会被 SQL 淹没影响定位问题。打包命令用mvn clean package然后复制 target 下的 war 到 Tomcat 的webapps。如果过程中有 test 失败加上-Dmaven.test.skiptrue。这些都是常规操作。5. 项目扩展与答辩加分点5.1 从 SSM 到 Spring Boot 的迁移思路如果你做完这套系统还有时间我强烈建议尝试把它迁移到 Spring Boot。思路并不难把pom.xml里的 spring-webmvc、spring-jdbc 替换为spring-boot-starter-webMyBatis 用mybatis-spring-boot-starter原来那一堆 xml 配置换成application.yml里的数据源、Mapper 扫描和事务配置。Controller 的路径尽量不改JSP 部分可以直接抽空改成RestController JSON前端页面换成 Vue 或原生 HTML。迁移的意义不只是“换技术”而是让你真正理解自动配置的价值。比如 Spring Boot 里的DataSourceAutoConfiguration会根据classpath上的依赖自动推断连接池和驱动不再需要手写DruidDataSource的 bean。这个过程做一遍面试问到“Spring Boot 为什么不推荐用 JSP”时你会比背答案的人从原理上更清楚。5.2 答辩时如何介绍这个系统答辩和面试经常让人紧张但只要你把两件事讲明白基本就稳了第一你设计项目时解决了什么问题第二你在项目里掌握了哪些框架原理。这套超市系统最好的切入点就是“库存和事务的一致性”和“多条件查询的动态 SQL”。与其讲十个增删改查页面不如拿一次销售事务的过程展开说明你理解Transactional的回滚机制、传播行为以及为什么不能把异常吞掉。如果你觉得项目还有往上提的空间可以主动说“库存预警、数据权限、并发扣库存优化”这些预留点。只要有一个点你讲得足够细老师或面试官就不会继续追问边角料。这也是我在实际交流里发现最实用的答辩策略。整套系统跑起来之后我个人最大的体会是课程设计阶段把 SSM 的配置亲手写过一遍后面学 Spring Boot 真的会轻松很多因为你能看懂它在自动配置背后做了什么。如果你接下来想在这套系统上继续扩展我的建议是先做两个方向一个是把登录功能换成基于 token 的接口鉴权另一个是把销售明细里的商品数量引入乐观锁防止并发扣库存。数据库设计阶段多花一个小时后面写代码能省半天这句话是我这次做得最值的一条经验希望你也用得上。