资讯动态

SSM菜品管理系统实战:从骨架搭建到二次开发避坑指南

发布时间:2026/10/23 7:10:14 来源:尧图企业网站定制
简介SSM菜品管理系统是一套基于Spring、Spring MVC与MyBatis三大框架搭建的Java Web实战项目面向Java Web初学者及需要理解MVC分层架构的开发者用于实现餐厅菜品信息的录入、查询、修改与删除并涵盖分类管理、订单管理等常见业务场景。压缩包共98个文件约17.52MB以40个jar依赖包、12个xml配置、10个java源码与10个class文件为主另含jsp页面、js脚本、properties配置及Eclipse工程文件完整呈现了从依赖到配置再到源码的工程结构。目前已有1051人学习下载。项目清晰展示了Spring的依赖注入与AOP事务管理、Spring MVC的请求分发流程以及MyBatis通过Mapper接口与XML动态执行SQL的持久层写法读者可据此理解各层协作方式、数据库表间关联设计及登录认证思路是学习SSM整合与企业级开发实践的实用参考。1. SSM菜品管理系统一个能直接跑通的后台管理骨架接手一个餐饮类后台项目时最怕的不是功能多而是骨架散。菜单、分类、订单、用户这几张表一旦各写各的后面加个统计报表就得推倒重来。SSM菜品管理系统解决的正是这个问题它把 Spring SpringMVC MyBatis 这套经典组合落成一个可运行的菜品管理后台覆盖菜品增删改查、分类维护、图片上传、分页检索这些餐饮后台的高频动作。适合两类人一是刚学完 SSM 想找个完整项目练手的开发者二是需要快速搭一个餐饮管理原型、再往上叠业务的小团队。它不花哨但结构清楚改起来不别扭这一点比很多功能大而全的模板更值钱。2. 技术栈与目录结构先看清骨架再动手2.1 为什么是 SSM 而不是 SpringBoot这个项目选 SSM 不是守旧而是教学和二次开发的成本考量。SpringBoot 把配置藏进自动装配新手跑起来快但出了问题不知道从哪查SSM 的每一层配置都摊在明面上applicationContext.xml管 Bean 和事务spring-mvc.xml管视图解析和静态资源mybatis-config.xml管别名和插件web.xml管 DispatcherServlet 和编码过滤器。四份配置各司其职改一个数据源、换一个拦截器位置一目了然。常见做法是 Maven 多模块或单模块分包这个项目走单模块分包的路线依赖集中在pom.xml里核心依赖大致是这几块依赖作用版本策略spring-context / spring-webmvcIoC 容器与 MVC 调度与 spring-jdbc 保持同一版本线mybatis mybatis-springORM 与 Spring 整合注意 mybatis-spring 与 MyBatis 主版本匹配mysql-connector-java数据库驱动与本地 MySQL 大版本对齐druid连接池可选替换默认池时改 dataSource 配置jsp / jstl视图层与 Servlet 容器版本兼容fileupload / io图片上传注意 commons-io 与 fileupload 的配套版本号我不在这里写死因为不同 JDK 和 Tomcat 组合下最优解不一样。判断标准很简单mvn dependency:tree不报冲突、启动不抛NoSuchMethodError就是对的组合。2.2 目录结构逐层拆一个能跑起来的 SSM 项目目录基本长这样我按职责标注了每一层该放什么src/main/java/com/example/dish/ ├── controller/ # 接收请求参数校验返回视图或 JSON ├── service/ # 业务接口 │ └── impl/ # 业务实现事务边界在这里 ├── mapper/ # MyBatis 接口与 XML 一一对应 ├── entity/ # 与表结构对应的 POJO ├── vo/ # 视图对象比如带分类名的菜品 └── util/ # 上传、分页、统一返回等工具 src/main/resources/ ├── mapper/ # Mapper XMLSQL 写在这里 ├── spring/ # 各层 Spring 配置 ├── mybatis-config.xml └── jdbc.properties # 数据源参数 src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面 ├── static/ # css、js、上传图片目录 └── index.jsp这个分层的关键在于controller不碰 SQL、service不碰 HttpServletRequest、mapper只做数据访问。很多新手图省事把查询逻辑写进 Controller项目小的时候没事一旦要加缓存或事务就全乱。这个项目的分层是干净的二次开发时你只需要在对应层加方法不用跨层改。2.3 配置文件之间的依赖关系四份配置不是孤立的启动顺序和引用关系决定了你改错一个地方会连锁报错。web.xml里通过context-param加载applicationContext.xml再由它import引入数据源和事务配置DispatcherServlet单独加载spring-mvc.xml后者只扫描controller包前者扫描service和mapper。这个父子容器设计是 SSM 的经典坑点如果spring-mvc.xml也扫了 service 包事务会失效因为子容器里的 Bean 不走父容器的事务代理。!-- applicationContext.xml 关键片段父容器只扫 service 和 mapper -- context:component-scan base-packagecom.example.dish.service/ context:component-scan base-packagecom.example.dish.mapper/ !-- spring-mvc.xml 关键片段子容器只扫 controller -- context:component-scan base-packagecom.example.dish.controller/参数说明base-package的划分就是父子容器的边界service必须在父容器controller必须在子容器两者不要重叠。事务管理器DataSourceTransactionManager配在父容器tx:annotation-driven也配在父容器这样Transactional才生效。我见过太多项目因为这一行放错位置导致插入失败不回滚排查半天。3. 核心模块拆解从一次菜品查询看完整链路3.1 实体与表结构对齐菜品表是整个系统的中心字段设计直接决定后面查询和统计好不好写。一个够用的dish表大致包含主键id、名称name、分类外键category_id、价格price、图片路径image、描述description、状态status上架/下架、创建时间create_time。分类单独一张category表用id和name两个字段就够别急着加层级餐饮后台的分类通常一层就够用。CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, category_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, image VARCHAR(255) DEFAULT NULL, description VARCHAR(500) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明price用DECIMAL不用FLOAT金额计算不能有精度误差status用TINYINT而不是字符串索引效率高idx_category是为按分类筛选准备的菜品列表页几乎一定会按分类过滤。utf8mb4是为了支持菜品名里的特殊字符和 emoji别用utf8那个是三字节的旧编码。实体类Dish与表字段一一对应但列表页需要显示分类名称单靠Dish拿不到所以加一个DishVO继承或组合Dish多一个categoryName字段。这是 VO 存在的意义不污染实体又能承载联表结果。3.2 Mapper 层SQL 与接口的对应Mapper 接口只声明方法SQL 写在 XML 里这是 MyBatis 的标准玩法。菜品分页查询是使用频率最高的方法它同时涉及条件筛选、联表和分页三个动作// DishMapper.java ListDishVO 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);!-- DishMapper.xml -- select idselectByPage resultTypecom.example.dish.vo.DishVO SELECT d.*, c.name AS categoryName FROM dish d LEFT JOIN category c ON d.category_id c.id where if testkeyword ! null and keyword ! AND d.name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND d.category_id #{categoryId} /if /where ORDER BY d.create_time DESC LIMIT #{offset}, #{limit} /select逻辑说明where标签会自动处理第一个AND避免拼接出WHERE AND的语法错误#{}是预编译占位符能防 SQL 注入别用${}拼关键词。LEFT JOIN保证即使分类被删菜品仍能查出来categoryName为 null 时前端显示未分类。分页用LIMIT offset, limitoffset由 Service 层按(pageNum - 1) * pageSize算出来Mapper 不关心页码只认偏移量这样职责清晰。countByCondition的 SQL 与查询条件完全一致只是把SELECT换成COUNT(*)并去掉LIMIT。两段 SQL 的条件必须同步改否则会出现总数对不上列表的经典 bug。我一般会把条件抽成sql片段复用sql idcondition where if testkeyword ! null and keyword ! AND d.name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND d.category_id #{categoryId} /if /where /sql然后在两个语句里都用include refidcondition/引用改一处两处生效这是省后悔药的做法。3.3 Service 层事务与分页封装Service 层是业务规则的落点也是事务边界。菜品新增时往往要同时写菜品表和更新分类的统计字段如果有这两个操作必须在一个事务里Service public class DishServiceImpl implements DishService { Autowired private DishMapper dishMapper; Override Transactional(rollbackFor Exception.class) public void addDish(Dish dish) { if (dish.getName() null || dish.getName().trim().isEmpty()) { throw new IllegalArgumentException(菜品名称不能为空); } dish.setCreateTime(new Date()); dishMapper.insert(dish); } Override public PageResultDishVO page(int pageNum, int pageSize, String keyword, Long categoryId) { int offset (pageNum - 1) * pageSize; ListDishVO list dishMapper.selectByPage(keyword, categoryId, offset, pageSize); int total dishMapper.countByCondition(keyword, categoryId); return new PageResult(list, total, pageNum, pageSize); } }参数说明Transactional(rollbackFor Exception.class)里的rollbackFor不能省Spring 默认只对RuntimeException回滚受检异常不回滚这是血泪经验。page方法里先查列表再查总数两次查询之间数据可能变化对后台管理来说可以接受如果要求严格一致得用同一事务包起来但会牺牲并发。PageResult是个简单封装装list、total、pageNum、pageSize四个字段前端拿它渲染分页条。3.4 Controller 层参数接收与视图返回Controller 负责把 HTTP 请求翻译成 Service 调用再把结果交给视图。菜品列表页是典型的接收筛选参数 返回 JSPController RequestMapping(/dish) public class DishController { Autowired private DishService dishService; GetMapping(/list) public String list(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String keyword, RequestParam(required false) Long categoryId, Model model) { PageResultDishVO page dishService.page(pageNum, pageSize, keyword, categoryId); model.addAttribute(page, page); model.addAttribute(keyword, keyword); model.addAttribute(categoryId, categoryId); return dish/list; } }逻辑说明RequestParam的defaultValue让分页参数缺省时也有值避免null转int抛异常required false让筛选条件可选。把keyword和categoryId回填到Model是为了 JSP 里搜索框和下拉框能保持用户的选择不然翻页后筛选条件就丢了这是体验细节但很影响使用。返回字符串dish/list由spring-mvc.xml里的InternalResourceViewResolver拼成/WEB-INF/views/dish/list.jsp。4. 跑起来环境准备到首次访问4.1 环境清单与版本对齐跑 SSM 项目环境对齐比写代码更耗时间。需要的东西JDK 8 或 11别用太高版本老版本 Spring 对高 JDK 反射限制敏感、Maven 3.6、MySQL 5.7 或 8.0、Tomcat 8.5 或 9。IDE 用 IntelliJ IDEA 的话注意配好 Tomcat 的 Artifact否则部署出来是 404。版本对齐的判断方法pom.xml里 Spring 版本和 JDK 版本要匹配Spring 5.x 支持 JDK 8 到 11 比较稳MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver连接串要带serverTimezone否则启动报时区错误。这些不是玄学是版本组合的硬约束。4.2 数据库初始化与连接配置先把建表脚本跑一遍建库、建表、插几条测试数据。然后改jdbc.propertiesjdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/dish_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的密码参数说明useUnicode和characterEncoding保证中文不乱码serverTimezone必须显式指定MySQL 8 驱动不设会抛The server time zone value is unrecognizeduseSSLfalse是本地开发省去证书配置生产环境要开。改完确认applicationContext.xml里的dataSource引用了这些占位符Druid 或默认池都能用。4.3 启动与首次访问验证用 Maven 打包mvn clean package把target下的 war 丢进 Tomcat 的webapps启动 Tomcat访问http://localhost:8080/项目名/dish/list。如果看到列表页和分页条说明链路通了。验证顺序建议按数据库连接 → Mapper 查询 → Controller 路由 → JSP 渲染逐层看日志连不上库看jdbc.properties查询报错看 Mapper XML 的namespace和id是否与接口一致404 看web.xml的url-pattern和 Controller 的RequestMapping页面空白看视图解析器前缀后缀。提示首次启动如果报ClassNotFoundException九成是依赖没打进WEB-INF/lib检查 IDEA 的 Artifact 配置里是否包含了 Maven 依赖。5. 避坑与排查五个真实会卡住你的点5.1 中文乱码从请求到响应三处都要管现象新增菜品后列表显示问号或者搜索中文关键词查不到。原因编码链路有三段——请求体、数据库连接、响应输出任何一段没设 UTF-8 都会乱。解决web.xml里配CharacterEncodingFilter且forceEncoding设 true数据库和表用utf8mb4JSP 页头写% page contentTypetext/html;charsetUTF-8 %。三处齐了才彻底。5.2 事务不生效父子容器扫描重叠现象Service 方法加了Transactional但插入失败后数据还在。原因spring-mvc.xml把 service 包也扫了子容器里的 Bean 没有事务代理。解决子容器只扫controller父容器扫service和mappertx:annotation-driven放父容器。改完重启验证故意让第二个插入抛异常看第一个是否回滚。5.3 图片上传后访问 404现象上传成功数据库有路径但页面图片裂开。原因上传目录在webapp外或者spring-mvc.xml没配静态资源映射。解决上传到webapp/static/upload/下并在spring-mvc.xml加mvc:resources mapping/static/** location/static//。注意DispatcherServlet的url-pattern是/时会拦截静态资源这行配置就是放行。5.4 分页总数与列表对不上现象翻到最后一页出现空数据或总数比实际多。原因countByCondition和selectByPage的条件没同步或者offset算错。解决条件抽sql片段复用offset用(pageNum - 1) * pageSizepageNum从 1 开始别从 0 开始。改完用边界值测第一页、最后一页、超出页码。5.5 Mapper 绑定失败namespace 与接口路径不一致现象启动报Invalid bound statement (not found)。原因XML 的namespace没写全接口路径或mybatis-config.xml的mapperLocations没覆盖到 XML 目录。解决namespace必须是接口全限定名mapperLocations写成classpath:mapper/*.xml接口方法名与 XML 的id严格一致大小写都不能差。6. 二次开发从菜品管理扩到订单模块的改造思路拿到这个骨架后最常见的二次开发是加订单。我的做法不是另起一套而是复用现有分层把订单当成菜品 用户 状态机的组合。第一步在entity加Order和OrderItemOrderItem里存dish_id和下单时的价格快照——价格快照这个字段千万别省菜品调价后历史订单金额不能跟着变这是餐饮系统的基本要求。第二步在mapper加OrderMapper下单和扣库存放在同一个Transactional方法里库存字段加在dish表上用UPDATE dish SET stock stock - #{num} WHERE id #{id} AND stock #{num}这种带条件的更新防超卖返回影响行数为 0 就抛异常回滚。验证方法很直接写一个并发测试用线程池同时下 100 单同一菜品库存设 50最后看成功单数是否正好 50、库存是否为 0。这个测试能同时验证事务边界和防超卖 SQL比看代码靠谱。改造时还有一个技巧把PageResult和统一返回结构抽到util包订单列表直接复用分页逻辑不用重写。改造点落点注意订单表entity 建表脚本金额用 DECIMAL存价格快照防超卖mapper条件更新判断影响行数别先查后改事务service方法下单与扣库存同一事务分页复用util的 PageResult条件 SQL 抽片段从那以后我每次接这类后台项目都强制先把父子容器扫描范围和事务配置走一遍验证再动业务代码——骨架没理顺就加功能后面全是补丁摞补丁。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑