
做JavaWeb课程设计或者毕业设计时很多人都绕不开“外卖订餐系统”这个题目。它不像库存管理那么枯燥又不会像电商那么复杂正好能展示SSM框架的整合能力。我当年也是从零开始用JavaSSMJSPjQueryAjaxMySQL把这套系统搭出来踩了不少坑也攒了不少经验。这篇文章就从项目角度完整拆解一遍从技术选型、数据库设计、后台功能到前端交互再到最后的部署把所有值得注意的细节都说清楚。无论你是刚开始接触SSM还是正在做类似项目应该都能用得上。1. 为什么外卖订餐系统选了SSM这套老牌组合1.1 SSM框架在课程设计和毕设里的位置先说说技术选型。现在一提到新项目很多人会去追逐Spring Boot、Vue、前后端分离但放到课程设计和毕业设计的场景里SSMSpringSpringMVCMyBatis依然是出现频率最高的一套组合。原因很现实学校课程大多还在讲SSM教材和参考代码容易找老师答辩时也熟悉这套架构。更重要的是SSM足够轻量能让你把每个配置文件的每行代码都讲清楚这在答辩环节是非常加分的。外卖订餐系统恰好是SSM最典型的使用场景它有用户端、商家端和管理端涉及用户登录、菜品浏览、购物车、订单提交、订单状态更新等操作。这类业务的CRUD特征明显同时又有不少交互细节特别适合用SpringMVC做控制层、MyBatis做数据层、JSP做视图层来演示一个完整闭环。你如果换成了Spring Boot虽然更省事但“框架自动配置”的特性会让很多细节被遮住答辩时反而容易说不清。1.2 技术选型的权衡JSP和前端分离我该怎么选很多人纠结要不要用前后端分离。我的建议是如果是课程设计或毕设就老老实实用JSPjQueryAjax。前后端分离意味着你要多维护一套前端工程还要处理跨域对时间紧张的项目来说不划算。JSP虽然不是主流但和SSM整合起来非常匹配SpringMVC可以直接返回视图名JSP里又能用JSTL渲染数据配合jQuery发Ajax请求完全能做出流畅的交互体验。当时我为了图新鲜想用VueSSM做一个完全分离的版本结果光是跨域配置就折腾了好几天。后来回到JSP方案一个项目包扔进Tomcat就能跑起来效率翻了不止一倍。所以除非老师明确要求否则别给自己找麻烦。1.3 项目整体模块规划我做的这套外卖订餐系统核心模块包括用户管理注册、登录、个人信息修改、地址管理。菜品管理菜品分类、菜品列表、菜品上下架管理员。购物车添加菜品、修改数量、删除、清空。订单管理提交订单、订单列表、订单详情、取消订单、配送状态修改。后台管理管理员登录、用户管理、订单统计。从数据库层面看就是用户表、菜品表、分类表、购物车表、订单表、订单明细表这几张核心表。功能不贪多足够展示一个完整项目的开发流程也符合老师对“工作量”的要求。先把这个模块图印在脑子里后面每一步都是为了支撑这些模块。2. 数据库设计订单、菜品、用户三大核心表怎么建模2.1 用户表与角色设计外卖系统里至少有两种角色普通用户和管理员。设计users表时我加了一个role字段用整数区分1表示管理员0表示普通用户。这样可以避免建多张用户表登录时只查一张表然后根据role跳转到不同首页。这里有一个容易忽略的点密码字段的长度。如果计划用MD5加密存储的是32位十六进制字符串字段长度至少设为32。很多新手设成20结果加密后一存就报Data too long。我更建议用BCrypt但课程设计里MD5已经够用老师也不会深究。另外注册时默认role为0这个可以用MyBatis的insert语句直接写死避免漏掉。2.2 菜品分类与购物车表的设计思路菜品表dishes和分类表category是简单的多对一关系菜品的category_id指向分类表的主键。分类表没必要做太复杂id、name、sort这三个字段足矣。sort用来控制前端展示顺序。购物车表是一个典型的多对多辅助表字段包括id、user_id、dish_id、quantity。我没有单独建购物车主表因为当前用户同时只能拥有一个购物车直接用user_id作为逻辑上的唯一约束。在MyBatis的Mapper里通过user_id关联到菜品表一次性查出购物车列表和菜品价格这样渲染就比较方便。这里要注意一个细节购物车表里的价格不要冗余存储菜品当时的单价。因为菜品价格可能变动如果购物车里存了旧价格结算时会不一致。正确做法是只存dish_id每次查询时join dishes表取当前价格。等到生成订单时再把单价快照存入订单明细表这样才能保证历史订单有据可查。2.3 订单表和明细表的拆分订单表orders和订单明细表order_items是外围系统里最常见的父子结构。为什么要拆因为一个订单会包含多个菜品如果每个订单都存一个冗余的菜品名称和价格列表会非常难维护。正确的做法是orders表只存订单编号、用户id、总金额、下单时间、状态、收货地址order_items表存订单id、菜品id、菜品名称、单价、数量。其中菜品名称和单价是快照不可变。订单状态我用int表示0未支付、1已支付/配送中、2已完成、-1已取消。这种设计在做统计和条件查询时非常高效。这里有一个经验状态字段的默认值建议在数据库层设置不要只在代码里set否则MyBatis插入时容易漏掉导致状态为null。我习惯在表结构里写DEFAULT 0代码里只做显式更新。订单编号我使用时间戳加用户id生成比如20240521105330_001。虽然并发下可能重复但课程设计场景足够了。如果真想更严谨可以加随机数或者数据库sequence不过没必要过度设计。3. 从零搭建SSM项目Maven配置与Spring整合3.1 pom.xml依赖清单用Maven管理依赖能省掉手动拷jar包的痛苦。这是我最基础的一份pom依赖覆盖了SSM所有需要的东西dependencies !-- Spring核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.2.10.RELEASE/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.2.10.RELEASE/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.2.10.RELEASE/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 !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.26/version /dependency !-- 数据库连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependency !-- JSP和JSTL -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- Jackson用于Ajax的JSON序列化 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.11.4/version /dependency /dependencies依赖版本号我曾吃过亏。一开始用了Spring 6和Java 17结果Tomcat 8不兼容折腾半天。课程设计环境里JDK 1.8 Spring 5.2 Tomcat 8.5是最稳的一套。如果你用的是Maven自带的Tomcat插件也要注意版本。稳妥起见直接用外置Tomcat部署war别在插件上浪费时间。3.2 Spring和MyBatis的配置要点SSM整合分三层配置文件spring-dao.xml负责数据源和MyBatisspring-mvc.xml负责控制器和视图解析applicationContext.xml统一引入。我这里直接写最重要的spring-dao.xml片段bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/waimai?serverTimezoneAsia/Shanghaiamp;useSSLfalseamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.course.pojo/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.course.mapper/ /bean这里有两个关键点。第一是MySQL驱动的url参数。MySQL 8.x下必须写上serverTimezoneAsia/Shanghai否则报时区错误useSSLfalse可以避免SSL连接警告这个就是很多人说“mysql ssl连接错误”的根源。第二是characterEncodingutf8配合后面的乱码处理能保证中文正常入库。当初我把url里的参数顺序写错导致连接超时后来才发现是符号在XML里必须转义成amp;否则XML解析直接报错。3.3 JSP视图解析和静态资源处理SpringMVC需要把Controller返回的字符串解析成真正的JSP路径。在spring-mvc.xml里配置bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /bean注意我把JSP页面放在了WEB-INF下面这样用户不能通过浏览器直接访问JSP只能通过Controller请求转发一方面安全一方面也强制走MVC流程。代价是路径上稍显繁琐但值得。JSP通常要引入静态资源比如CSS、JS。SpringMVC默认会拦截所有请求如果不单独放开静态资源/static/js/jquery.js会被DispatcherServlet拦截并报404。我习惯在spring-mvc.xml里加mvc:resources location/static/ mapping/static/**/ mvc:annotation-driven/这样http://localhost:8080/waimai/static/js/jquery.js就可以被正常访问。如果你用的是Maven多模块还有可能把静态资源放在src/main/webapp/static下打包后路径要保持一致。4. 后端核心功能实现登录鉴权、菜品搜索、下单流程4.1 用拦截器做登录权限控制外卖系统的所有用户操作都需要登录所以控制器里不能每个方法都写“判断session是否为空”这种重复代码。SpringMVC的拦截器是标准做法。我写一个LoginInterceptorpublic 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) { // 异步请求需要返回JSON提示跳转 if (XMLHttpRequest.equals(request.getHeader(X-Requested-With))) { response.setContentType(application/json;charsetutf-8); response.getWriter().write({\code\:401,\msg\:\not login\}); } else { response.sendRedirect(request.getContextPath() /user/login); } return false; } return true; } }这里需要注意Ajax请求和普通请求在拦截器里的处理方式不同。如果用户session过期后发起的是一个Ajax请求直接sendRedirect是无效的前端收到的其实是被重定向后的登录页HTML然后JSON解析会失败。所以我在拦截器里判断了X-Requested-With对ajax请求返回状态码再由前端根据这个状态码强制刷新页面或跳转登录。这个细节很多人没处理导致“自动跳转登录页”功能时好时坏。4.2 菜品列表用Ajax异步加载为了让用户浏览菜品时不用每次都刷新整个页面我用jQuery的$.get或$.post请求菜品数据Controller返回JSON。这里就体现出了Jackson依赖的作用。举个例子前端首页加载时请求$(function () { $.ajax({ url: /waimai/dish/listByCategory, type: POST, data: {categoryId: 1}, dataType: json, success: function (result) { // 渲染菜品卡片 }, error: function () { alert(加载失败); } }); });Controller里的方法用ResponseBody返回对象RequestMapping(/dish/listByCategory) ResponseBody public Result listByCategory(RequestParam(categoryId) Integer categoryId) { ListDish dishList dishService.findByCategory(categoryId); return Result.success(dishList); }这样前端就能用JSON数据动态拼接页面。但这里有个大坑如果Controller方法所在的类没有被mvc:annotation-driven/覆盖或者Jackson依赖冲突返回的往往不是JSON而是报错页面。排查的时候先看浏览器Network里的响应体是什么如果是500页面基本就是序列化问题。另外如果你的Dish对象里含有Mapper级别的关联懒加载对象Jackson序列化时还可能报Jackson库无法序列化MyBatis代理对象。最简单的解决办法是只返回一个轻量VO类不要直接把实体类带出去。4.3 下单事务控制与库存扣减下单这个操作涉及多个表写入创建订单主表、插入订单明细、清空购物车、扣减菜品库存。如果中途抛异常就会造成数据不一致比如订单生成了但购物车没清空。所以service层一定要用Spring事务。Service public class OrderServiceImpl implements OrderService { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Autowired private CartMapper cartMapper; Autowired private DishMapper dishMapper; Override Transactional(rollbackFor Exception.class) public boolean createOrder(OrderDTO orderDTO) { // 1. 计算总金额并生成订单 Order order new Order(); order.setOrderNo(...); order.setUserId(orderDTO.getUserId()); order.setStatus(0); order.setTotalAmount(...); orderMapper.insert(order); // 获取自增id int orderId order.getId(); // 2. 遍历购物车菜品插入明细 for (CartItem item : orderDTO.getItems()) { OrderItem orderItem new OrderItem(); orderItem.setOrderId(orderId); orderItem.setDishId(item.getDishId()); orderItem.setDishName(item.getDishName()); orderItem.setPrice(item.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); // 3. 扣减库存 dishMapper.decreaseStock(item.getDishId(), item.getQuantity()); } // 4. 清空购物车 cartMapper.deleteByUserId(orderDTO.getUserId()); return true; } }Transactional(rollbackFor Exception.class)这个配置很关键。Spring默认只对RuntimeException回滚如果你抛的是checked exception比如IOException事务是不回滚的。所以时刻记住只要是可能让数据变残缺的异常就用rollbackFor指定Exception.class。同时还要理解MyBatis的useGeneratedKeys。在orderMapper.xml里插入订单后要能拿到自增主键insert idinsert parameterTypecom.course.pojo.Order useGeneratedKeystrue keyPropertyid insert into orders(...) values(...) /insert否则你后面插入order_item时没有orderId全白做。5. JSPjQueryAjax前端交互的坑与优化5.1 JSP页面的脚本放置和事件绑定JSP页面的HTML可以和Java代码混写但为了可维护性我建议把Java的输出尽量限定在渲染初始数据上比如用JSTL的c:forEach循环打印菜品列表之后的所有交互都交给jQuery。不要在JSP脚本片段里写一大坨out.println不仅难看调试也痛苦。事件绑定时尤其要注意动态加载的元素无法直接绑定事件。比如你通过Ajax加载了菜品卡片然后在卡片上点击“加入购物车”如果用$(.add-btn).click(...)第一次加载时没问题但Ajax更新后的新卡片就失效了。解决办法是用事件委托$(document).on(click, .add-btn, function () { var dishId $(this).data(dish-id); // 发送加入购物车请求 });事件委托的原理是把事件绑定到document上利用事件冒泡只要触发的目标元素匹配.add-btn就执行逻辑。这样无论是初始页面还是Ajax新增的元素都能响应。这个知识点在面试里也常问很多同学确实答不上来。5.2 Ajax请求设置编码格式避免中文乱码Ajax请求的中文乱码问题是另一个高频坑。表单提交时浏览器会根据页面charset编码而Ajax请求默认也跟随页面编码但如果你直接发送带中文的参数在POST请求里通常没问题在GET请求里就可能出现乱码。我在项目里统一做了两件事。第一所有Ajax请求都显式声明$.ajaxSetup({ contentType: application/x-www-form-urlencoded; charsetutf-8 });第二在web.xml里配置SpringMVC的编码过滤器filter filter-namecharacterEncodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueutf-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-namecharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping这个Filter要放在所有过滤器的最前面尤其是不能放在登录拦截器之后否则请求参数进入Controller时已经被错误编码了。我还遇到过数据库连接池和JDBC之间编码不一致的情况所以MySQL连接url里也必须带characterEncodingutf8三者保持一致后乱码就彻底消失了。5.3 购物车局部刷新和表单提交冲突购物车按钮的常见交互是点击“加入购物车”后右上角的购物车图标要动态数量1。我用了“局部刷新”思路而不刷新整个页面。实现方式很简单Ajax请求成功后返回当前购物车总数量然后用jQuery更新DOM。但有一个很微妙的冲突如果“提交订单”按钮是表单提交而购物车加减按钮是Ajax两者都放在同一页面时点回车键可能会意外触发表单提交。我一般给Ajax按钮加上typebutton并且在事件回调里return false;防止默认行为。同时给表单加onsubmitreturn checkOrder();做校验校验通过才真正提交。这是很多新手容易忽略的结果测试时总发现购物车数量莫名其妙变化其实就是页面被刷新导致Ajax状态丢失。6. 项目部署与调试本地Tomcat跑通的完整流程6.1 Maven打包war并运行开发完成后需要把项目打成war包放进Tomcat的webapps目录。在IDEA里直接执行mvn clean package如果之前没配置好Maven这里会报各种依赖下载失败。我的建议是Maven的settings.xml里配好阿里云镜像否则第一次构建去中央仓库下载速度慢到怀疑人生。打包后你会看到target目录下有一个xxx.war文件。如果你用的是Tomcat把它复制到Tomcat安装目录的webapps下然后启动Tomcat。Tomcat会自动解压war包并部署。如果项目名是waimai-1.0.war访问路径就是http://localhost:8080/waimai-1.0/。这个路径在上下文里如果用/会导致Ajax请求404。为了好看我习惯把war包改名为waimai.war这样访问路径就是http://localhost:8080/waimai/。注意JSP页面里的所有Ajax路径凡是以/开头的前面都要加上${pageContext.request.contextPath}否则部署之后就会因为项目名不一致而404。6.2 MySQL安装和连接配置不少同学在本地安装MySQL时卡住。如果只是为了这个项目建议直接装MySQL 8.x然后用命令行或Navicat建库。安装MySQL时有一个坑记得记好初始密码。Windows上MySQL安装向导会让你输入root密码Linux上如果用rpm安装初始密码通常在日志里。如果你忘了密码可以跳过授权表重置不过折腾。我项目的数据库配置写在spring-dao.xml里。如果连接报error 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock通常是MySQL服务没启动。Linux下执行systemctl start mysqld或者service mysql start即可。Windows环境则去服务管理器里启动“MySQL80”服务。MySQL连接url里的serverTimezoneAsia/Shanghai在8.x版本是必须的。如果用的是5.x版本可以不写但为了通用性我建议保留。还有useSSLfalse这个参数能避免控制台刷出一堆“Establishing SSL connection”的警告。6.3 部署后常见的几个报错及定位方法我汇总了这套项目里最常出现的报错方便大家排查现象可能原因解决办法启动Tomcat后403错误未配置默认首页或目录访问被禁止检查web.xml中welcome-file确认Controller映射了/路径访问页面404项目上下文路径不对检查war包名称确认request.getContextPath()统一前缀MyBatis报BindingExceptionMapper接口和XML namespace不匹配检查mapper接口包名和namespace严格一致Ajax返回500Jackson序列化异常或服务端空指针看浏览器Network的响应体Controller里返回的VO避免循环引用中文乱码编码过滤器缺失、MySQL连接没有utf8参考上文统一所有环节为utf-8连接MySQL超时druid连接池配置问题检查连接池initialSize等参数trival问题可关掉druid再补充一个我要重点强调的JSP页面里的Java代码报错往往显示在Tomcat日志而不是浏览器页面上。如果页面白屏先去Tomcat的logs/catalina.out里看最后几行异常堆栈。我见过太多人在浏览器里反复刷新找错误根本找不到白白浪费大量时间。7. 从毕设到真实项目这套系统的扩展方向7.1 引入Redis做购物车和登录状态如果想把课程设计升华一下可以在答辩时讲几个扩展点其中成本最低、收益最大的是用Redis替代session存购物车。因为session是内存存储一旦服务器重启用户的购物车全没了。而Redis可以持久化还可以设置过期时间。Spring整合Redis的写法也不复杂在pom里加spring-boot-starter-data-redis如果用Spring Boot或spring-data-redis配合Jedis。你不需要真的把完整项目改掉只需在答辩PPT里展示“如果重构我会怎么改”效果就比只说“完成”强得多。7.2 订单状态的超时取消真实的外卖系统都会有“超时未支付自动取消订单”的逻辑。课程设计里可以用一个定时任务模拟比如Spring的Scheduled注解加上一个定时扫描方法每30秒查一次订单表把status0且创建时间超过15分钟的订单取消并恢复库存。这样涉及到了定时任务、批量更新和库存回滚是一个值得讲深的技术点。7.3 引入Maven多模块或前后端分离如果以后往企业方向走尽量把项目拆成waimai-common、waimai-pojo、waimai-mapper、waimai-service、waimai-web这样的多模块结构。现在很多工作三年的Java开发还在写单模块如果大二就能做出这种分层面试官会高看你一眼。前后端分离虽然不适合当毕设但可以自己在课余时间用Vue重写前端把后端API全部用ResponseBody返回JSON你的简历上又能多一个能聊的项目。7.4 多角色权限控制目前我用的是简单的role字段如果时间充裕可以借鉴Spring Security或Shiro的权限模型设计user_role、role_permission、permission三张表做成RBAC。外卖系统的角色其实很丰富用户、商家、骑手、管理员。每一类角色的操作权限不同做成RBAC之后后期扩展功能会非常方便。我个人实际开发中的体会是这套SSM外卖系统最大的价值不在于功能多完整而在于它能逼着你把“前端表单提交、Ajax交互、后端事务、数据库设计、项目部署”这一整条链路跑通。很多同学在学校里只学会了琐碎的知识点把这个项目完整做一遍后你会突然理解什么叫做“集成”。如果你现在正在做这个题目别慌按照我上面说的顺序先把数据库和配置搞定再从登录做到下单每一步都测试通过再进行下一步。最后那句老掉牙的话再强调一遍——代码自己敲一遍比看十遍教程都管用。希望这篇拆解能帮你少走我当初走过的弯路。