☰
SSM外卖订餐系统从零搭建:数据库设计到部署全流程解析
2026/9/29 15:51:08 网站建设 项目流程

做JavaWeb课程设计或者毕业设计时,很多人都绕不开“外卖订餐系统”这个题目。它不像库存管理那么枯燥,又不会像电商那么复杂,正好能展示SSM框架的整合能力。我当年也是从零开始,用Java+SSM+JSP+jQuery+Ajax+MySQL把这套系统搭出来,踩了不少坑,也攒了不少经验。这篇文章就从项目角度完整拆解一遍,从技术选型、数据库设计、后台功能到前端交互,再到最后的部署,把所有值得注意的细节都说清楚。无论你是刚开始接触SSM,还是正在做类似项目,应该都能用得上。

1. 为什么外卖订餐系统选了SSM这套老牌组合

1.1 SSM框架在课程设计和毕设里的位置

先说说技术选型。现在一提到新项目,很多人会去追逐Spring Boot、Vue、前后端分离,但放到课程设计和毕业设计的场景里,SSM(Spring+SpringMVC+MyBatis)依然是出现频率最高的一套组合。原因很现实:学校课程大多还在讲SSM,教材和参考代码容易找,老师答辩时也熟悉这套架构。更重要的是,SSM足够轻量,能让你把每个配置文件的每行代码都讲清楚,这在答辩环节是非常加分的。

外卖订餐系统恰好是SSM最典型的使用场景:它有用户端、商家端和管理端,涉及用户登录、菜品浏览、购物车、订单提交、订单状态更新等操作。这类业务的CRUD特征明显,同时又有不少交互细节,特别适合用SpringMVC做控制层、MyBatis做数据层、JSP做视图层来演示一个完整闭环。你如果换成了Spring Boot,虽然更省事,但“框架自动配置”的特性会让很多细节被遮住,答辩时反而容易说不清。

1.2 技术选型的权衡:JSP和前端分离我该怎么选

很多人纠结要不要用前后端分离。我的建议是:如果是课程设计或毕设,就老老实实用JSP+jQuery+Ajax。前后端分离意味着你要多维护一套前端工程,还要处理跨域,对时间紧张的项目来说不划算。JSP虽然不是主流,但和SSM整合起来非常匹配,SpringMVC可以直接返回视图名,JSP里又能用JSTL渲染数据,配合jQuery发Ajax请求,完全能做出流畅的交互体验。

当时我为了图新鲜,想用Vue+SSM做一个完全分离的版本,结果光是跨域配置就折腾了好几天。后来回到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> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.2.10.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.10.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.2.10.RELEASE</version> </dependency> <!-- MyBatis和整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.6</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.26</version> </dependency> <!-- 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <!-- JSP和JSTL --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- Jackson用于Ajax的JSON序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.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负责数据源和MyBatis,spring-mvc.xml负责控制器和视图解析,applicationContext.xml统一引入。我这里直接写最重要的spring-dao.xml片段:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/waimai?serverTimezone=Asia/Shanghai&amp;useSSL=false&amp;characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.course.pojo"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.course.mapper"/> </bean>

这里有两个关键点。第一是MySQL驱动的url参数。MySQL 8.x下,必须写上serverTimezone=Asia/Shanghai,否则报时区错误;useSSL=false可以避免SSL连接警告,这个就是很多人说“mysql ssl连接错误”的根源。第二是characterEncoding=utf8,配合后面的乱码处理,能保证中文正常入库。当初我把url里的参数顺序写错,导致连接超时,后来才发现是&符号在XML里必须转义成&amp;,否则XML解析直接报错。

3.3 JSP视图解析和静态资源处理

SpringMVC需要把Controller返回的字符串解析成真正的JSP路径。在spring-mvc.xml里配置:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" 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的拦截器是标准做法。我写一个LoginInterceptor:

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) { // 异步请求需要返回JSON提示跳转 if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setContentType("application/json;charset=utf-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) { List<Dish> 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 id="insert" parameterType="com.course.pojo.Order" useGeneratedKeys="true" keyProperty="id"> insert into orders(...) values(...) </insert>

否则你后面插入order_item时没有orderId,全白做。

5. JSP+jQuery+Ajax前端交互的坑与优化

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; charset=utf-8' });

第二,在web.xml里配置SpringMVC的编码过滤器:

<filter> <filter-name>characterEncodingFilter</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> <filter-mapping> <filter-name>characterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

这个Filter要放在所有过滤器的最前面,尤其是不能放在登录拦截器之后,否则请求参数进入Controller时已经被错误编码了。我还遇到过数据库连接池和JDBC之间编码不一致的情况,所以MySQL连接url里也必须带characterEncoding=utf8,三者保持一致后,乱码就彻底消失了。

5.3 购物车局部刷新和表单提交冲突

购物车按钮的常见交互是:点击“加入购物车”后,右上角的购物车图标要动态数量+1。我用了“局部刷新”思路,而不刷新整个页面。实现方式很简单:Ajax请求成功后,返回当前购物车总数量,然后用jQuery更新DOM。

但有一个很微妙的冲突:如果“提交订单”按钮是表单提交,而购物车加减按钮是Ajax,两者都放在同一页面时,点回车键可能会意外触发表单提交。我一般给Ajax按钮加上type="button",并且在事件回调里return false;,防止默认行为。同时给表单加onsubmit="return 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): Can't connect to local MySQL server through socket '/tmp/mysql.sock',通常是MySQL服务没启动。Linux下执行systemctl start mysqld或者service mysql start即可。Windows环境则去服务管理器里启动“MySQL80”服务。

MySQL连接url里的serverTimezone=Asia/Shanghai在8.x版本是必须的。如果用的是5.x版本,可以不写,但为了通用性,我建议保留。还有useSSL=false,这个参数能避免控制台刷出一堆“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秒查一次订单表,把status=0且创建时间超过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交互、后端事务、数据库设计、项目部署”这一整条链路跑通。很多同学在学校里只学会了琐碎的知识点,把这个项目完整做一遍后,你会突然理解什么叫做“集成”。如果你现在正在做这个题目,别慌,按照我上面说的顺序,先把数据库和配置搞定,再从登录做到下单,每一步都测试通过再进行下一步。最后那句老掉牙的话再强调一遍——代码自己敲一遍,比看十遍教程都管用。希望这篇拆解能帮你少走我当初走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询