☰
SSM框架JavaWeb在线点餐系统源码解析:从架构到核心代码
2026/10/15 22:23:48 网站建设 项目流程

简介:一份基于JavaWeb技术实现的必胜客在线订餐系统项目代码,面向JavaWeb初学者、课程设计或毕业设计选题学生。项目完整模拟在线订餐流程,涵盖用户注册登录、菜品浏览、购物车、下单支付与后台订单管理等功能,前后端交互清晰,适合用来理解Servlet、JSP与JavaBean的协同工作方式。

资源包共1307个文件,压缩后大小约66.88MB,主要包含java源码、jsp动态页面、class编译文件、png/jpg/gif等图片素材、jar依赖库以及js/css前端样式脚本,还附带SQL数据库脚本,便于还原表结构与测试数据。目录划分了数据库脚本、源码及页面文件,结构清晰,方便按模块查阅。

已有2516人学习下载。通过实际运行与研读该项目,可以系统掌握MVC分层设计、JDBC数据库操作、订单状态流转以及常见Web项目的部署流程,也能从源码中学习异常处理与基础安全防范思路,是一款适合动手实践的JavaWeb完整案例。

1. 你拿到的是一套能跑通的完整点餐系统,不只是几个页面

有次 A 同学接手一个课程设计的 JavaWeb 源码包,打开后页面不少,但点几下就报错,最后花了两个晚上才搞明白购物车是放在 Session 里的,订单提交又卡在事务回滚上。这套在线订餐系统源码包解决的就是这类问题:它不是一个静态网页合集,而是把菜品展示、用户登录、购物车、订单提交、后台管理串成一条完整链路的 JavaWeb 项目。适合拿来练 SSM 整合、应付毕业设计,或者作为第一次接触完整项目结构的入门样本。项目里该有的分层都有,代码风格也比较规整,接下来我带你把它拆开看一遍。

2. 项目整体拆解:从 Maven 工程结构到数据库设计

2.1 技术选型与分层:典型的 SSM 组合,够用也好扩展

这个项目的骨架是 SSM,也就是 Spring MVC + Spring + MyBatis,前端用 JSP 配合 JSTL,数据库用 MySQL,构建工具是 Maven,部署在 Tomcat 上。这是 JavaWeb 课程设计和中小型内部系统里最常见的组合。Spring MVC 管路由和参数绑定,Spring 管 Service 层对象和事务,MyBatis 管 SQL 映射,各层职责非常清楚。

我在项目里看到的依赖版本也是这个年代的主流方案:Spring 5.x、MyBatis 3.x、MyBatis-Spring 的桥接包,数据库连接池用 Druid,JSON 解析用 Fastjson。分页没有自己造轮子,直接引入 PageHelper。这些组件的好处是资料多,遇到问题随便一搜就能找到答案。项目里 Servlet 相关依赖的 scope 基本都是 provided,因为 Tomcat 自带了 servlet-api,打包时不用重复带进去。

从分层上看,工程严格按 Controller → Service → DAO 顺序调用。Controller 只接收页面参数、调用 Service、把结果放进 Model,不写业务;Service 负责事务边界和业务规则;DAO 层只处理数据库读写。这样的分层对新手来说可能觉得绕,但对后续替换数据库、扩展小程序端都有好处。你要改造某个功能时,不用担心动了一个文件把整条链路搞崩。

2.2 目录结构:controller、service、dao、po、util 一目了然

看一个 JavaWeb 项目,我习惯先看包名和目录结构,因为包名暴露了作者的架构思路。这个项目的源码目录大概是下面这样:

src/main/java ├── com.demo.controller # 用户端和后台管理的 Controller │ ├── CartController.java │ ├── FoodController.java │ ├── OrderController.java │ ├── UserController.java │ └── admin │ └── AdminOrderController.java ├── com.demo.service # Service 接口 ├── com.demo.service.impl # Service 实现类,事务写在这里 ├── com.demo.dao # MyBatis 的 Mapper 接口 ├── com.demo.po # 实体类:User、Food、Order、OrderDetail ├── com.demo.dto # 参数对象,比如 OrderQuery ├── com.demo.vo # 返回给页面的视图对象 ├── com.demo.interceptor # 登录拦截器 └── com.demo.util # 订单号生成、随机数等工具类 src/main/resources ├── mapper # 每个 DAO 对应的 XML 文件 │ ├── FoodMapper.xml │ ├── OrderMapper.xml │ └── UserMapper.xml ├── spring-mvc.xml # Spring MVC 配置 ├── spring-mybatis.xml # 数据源、事务、Mapper 扫描配置 ├── db.properties # 数据库连接配置 └── log4j.properties src/main/webapp ├── static # css/js/images ├── WEB-INF │ ├── jsp # 页面文件 │ │ ├── index.jsp │ │ ├── cart │ │ ├── food │ │ ├── order │ │ └── admin │ └── web.xml

注意 resources 下的 spring-mybatis.xml 是核心,数据源、SqlSessionFactory、MapperScannerConfigurer、事务管理器都在这里声明。如果你打开项目发现启动报错,多数情况是这里面的 bean id 不一致,或者 db.properties 里的连接信息不对。web.xml 里配置了 Spring 容器启动监听器和 DispatcherServlet 的映射,映射路径是/,所以所有请求都会先进 Spring MVC,静态资源需要在 spring-mvc.xml 里单独放行。

2.3 数据库设计:用户、菜品、订单、订单项四张核心表

在线订餐系统的核心数据关系很简单,用户下单,一个订单包含多个菜品,因此订单和菜品是多对多关系,需要一张订单明细表做中间表。项目里实际建的也就是这四张表,外加一张菜品分类表。我按常见做法整理出核心表结构:

CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL, `nickname` VARCHAR(32), `phone` VARCHAR(20), `address` VARCHAR(128), `role` VARCHAR(10) DEFAULT 'user', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE `food` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `image` VARCHAR(128), `description` VARCHAR(255), `category_id` INT, `stock` INT DEFAULT 0, `sales` INT DEFAULT 0, `status` TINYINT DEFAULT 1 ); CREATE TABLE `orders` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` INT NOT NULL, `total_price` DECIMAL(10,2) NOT NULL, `status` TINYINT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME ); CREATE TABLE `order_detail` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_id` INT NOT NULL, `food_id` INT NOT NULL, `food_name` VARCHAR(64), `price` DECIMAL(10,2), `quantity` INT DEFAULT 1 );

订单表用 order_no 做业务流水号,避免直接把自增 id 暴露给用户;订单明细里冗余了一份 food_name 和 price,这是很实用的做法。菜品改名或者价格调整后,历史订单依然能显示下单时的快照,不用再去关联 food 表。food.stock字段是后来加的,原项目如果没做库存控制,你二次开发时一定要补上,否则会出现下单超卖的问题。

3. 核心模块代码走读:一次点餐从浏览器到 MySQL 的完整路径

3.1 菜品列表与购物车:Session 里存购物车的经典写法

用户进首页先看到的就是菜品列表,FoodController 从数据库取出菜品,放到 Model 里,JSP 用 JSTL 的 c:forEach 循环渲染成卡片。这个逻辑不难,重点在购物车。这个项目把购物车对象直接存在 Session 里,用户关闭浏览器前购物车一直有效,免去了读数据库的开销。Controller 的核心代码大致这样:

@Controller @RequestMapping("/cart") public class CartController { @Autowired private FoodService foodService; @RequestMapping("/add") public String add(@RequestParam("foodId") Integer foodId, @RequestParam(value = "quantity", defaultValue = "1") Integer quantity, HttpSession session) { Food food = foodService.getById(foodId); Cart cart = (Cart) session.getAttribute("CART"); if (cart == null) { cart = new Cart(); } cart.add(food, quantity); session.setAttribute("CART", cart); return "redirect:/cart/list"; } }

Cart 这个类并不在数据库里,它是运行期的内存对象,内部用 Map 维护购物项:

public class Cart { private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Food food, int quantity) { CartItem item = items.get(food.getId()); if (item == null) { item = new CartItem(); item.setFood(food); item.setQuantity(quantity); } else { item.setQuantity(item.getQuantity() + quantity); } items.put(food.getId(), item); } public BigDecimal getTotalPrice() { BigDecimal total = BigDecimal.ZERO; for (CartItem item : items.values()) { total = total.add(item.getFood().getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } return total; } }

购物车用 foodId 做 key,同一个菜品重复加入时只累加数量,不会生成重复行。金额计算必须用 BigDecimal,不能用 double,否则订单总额可能会出现 19.999999 这种精度问题,这是我建议你保留原写法的地方。注意这里没有把库存量考虑进去,如果菜品下架了,加入购物车时应该加一层 status 判断,这是改造时最先要补的逻辑。

3.2 订单提交与事务控制:扣库存和生成订单怎么保证不翻车

订单提交是整个项目最值得细看的部分,因为它同时涉及多张表写入,一旦中间一步失败,用户会看到下单成功但库存已扣的诡异情况。项目里用的是 Spring 声明式事务,Service 实现方法上加@Transactional,遇到任何 RuntimeException 都会回滚。关键代码大概是:

@Override @Transactional(rollbackFor = Exception.class) public Order submitOrder(Cart cart, Integer userId) { Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(0); // 0待支付,1已支付,2已取消,3已完成 order.setCreateTime(new Date()); // 先算订单总额 BigDecimal total = cart.getTotalPrice(); order.setTotalPrice(total); orderMapper.insert(order); // 再逐条写入订单明细,同时扣减库存 for (CartItem item : cart.getItems()) { OrderDetail detail = new OrderDetail(); detail.setOrderId(order.getId()); detail.setFoodId(item.getFood().getId()); detail.setFoodName(item.getFood().getName()); detail.setPrice(item.getFood().getPrice()); detail.setQuantity(item.getQuantity()); orderDetailMapper.insert(detail); int rows = foodMapper.decreaseStock(item.getFood().getId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("菜品库存不足:" + item.getFood().getName()); } } return order; }

库存扣减没有用先查再改的方式,而是直接在 SQL 里加条件判断,这一步很关键。对应的 Mapper XML 长这样:

<update id="decreaseStock"> UPDATE food SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{foodId} AND stock >= #{quantity} </update>

如果stock已经不满足条件,这条 UPDATE 影响的行数是 0,代码里检查rows == 0,直接抛异常触发事务回滚。这样就不会出现两个用户同时买最后一份菜品时都下单成功的问题。注意传统写法是SELECT stock FROM food WHERE id = ...,查到库存大于 0 再 UPDATE,但在并发场景下这两步之间有间隙,库存会被多扣,这个项目直接避开这个坑,值得学习。订单号生成用时间戳加随机数,目的是避免用户在下单后通过订单 id 猜测业务量。

3.3 后台订单管理:PageHelper 分页和多条件查询的写法

后台管理是这类系统必有的部分,管理员要按订单号、状态、下单时间范围筛选订单,还要分页。项目里没有用复杂的查询框架,而是靠 MyBatis 动态 SQL 加 PageHelper。Controller 里只要两行代码就能完成分页:

@RequestMapping("/admin/order/list") public String list(OrderQuery query, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, Model model) { PageHelper.startPage(pageNum, pageSize); List<OrderVO> list = orderService.queryOrderList(query); PageInfo<OrderVO> pageInfo = new PageInfo<>(list); model.addAttribute("pageInfo", pageInfo); return "admin/order_list"; }

PageHelper 的原理是拦截当前线程下一次执行的查询 SQL,自动拼接LIMIT语句,然后返回 PageInfo 对象,里面封装了总条数、总页数、当前页列表。动态查询条件全都收敛在 OrderMapper.xml 里:

<select id="selectByQuery" resultType="com.demo.vo.OrderVO"> SELECT o.*, u.username FROM orders o LEFT JOIN user u ON o.user_id = u.id <where> <if test="orderNo != null and orderNo != ''"> AND o.order_no LIKE CONCAT('%', #{orderNo}, '%') </if> <if test="status != null"> AND o.status = #{status} </if> <if test="beginTime != null"> AND o.create_time &gt;= #{beginTime} </if> <if test="endTime != null"> AND o.create_time &lt;= #{endTime} </if> </where> ORDER BY o.create_time DESC </select>

注意 MyBatis XML 里小于号<=不能直接写,必须转义成&lt;=,否则 XML 解析阶段就报错。OrderQuery 是一个纯参数对象,四个字段对应页面上的筛选框。管理员修改订单状态时,直接调orderMapper.updateStatus(orderNo, status),这条 SQL 要记得加WHERE order_no = #{orderNo},别用 id 更新。把订单号暴露在管理端 URL 上问题不大,但更新条件一定要用唯一键。

3.4 登录拦截与权限控制:一个拦截器管住用户端和后台

项目没有引入 Spring Security,因为用户端和管理端的权限规则不复杂,用一个 HandlerInterceptor 就够。这个拦截器接管所有请求,先判断 Session 里有没有登录用户,再对/admin/开头的路径做管理员角色校验:

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user = (User) request.getSession() .getAttribute("LOGIN_USER"); if (user == null) { response.sendRedirect( request.getContextPath() + "/user/login"); return false; } if (request.getRequestURI().contains("/admin/") && !"admin".equals(user.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return false; } return true; } }

这个判断方式依赖 URI 字符串包含/admin/,属于轻量级实现。启动时如果 JS、CSS 被拦截,就是 spring-mvc.xml 里没有放行静态资源。放行写法是这样的:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/user/login"/> <mvc:exclude-mapping path="/user/doLogin"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.demo.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

放行路径除了登录接口,还必须把/static/**排除掉,否则页面会失去所有样式。这里要注意 Session 中用户对象的 key 前后端要一致,Controller 里存的是LOGIN_USER,拦截器取的就是LOGIN_USER,改了其中一个另一个不会自动同步,这是新手最容易忽略的细节。

4. 避坑指南:从零跑通这个 JavaWeb 项目最常踩的五个坑

4.1 现象:Tomcat 启动成功但访问首页一直 404

原因不是代码问题,而是项目根本没部署到正确位置。用 IDEA 跑 Web 项目时要检查 Artifacts 里有没有把项目打包成 war exploded,Deployment 标签页里 Application context 都要设置。如果直接拿源码包 import,有时候 IDEA 没识别出 Web Facet,就不会把 webapp 目录挂到 Tomcat 上。

解决:右键项目打开 Project Structure,在 Facets 里给模块添加 Web,指定 web.xml 路径;在 Artifacts 里新建 Web Application Exploded,把 Available Elements 中该项目的 jar 包和资源全部双击放进 WEB-INF/lib 和 classes 目录。完成后重启 Tomcat,确认输出日志里出现了项目 context path,再访问/项目名/。

4.2 现象:启动时报数据库连接失败,错误里有一长串服务器时区信息

原因:MySQL 8.x 的驱动对时区和 SSL 校验比 5.x 严格,很多源码包里的 db.properties 还是 5.x 时代的写法。也有可能是 mysql-connector-java 版本和本地 MySQL 版本不匹配,驱动类找不到。

解决:把 db.properties 里数据库连接 URL 改成兼容写法:

jdbc:mysql://localhost:3306/pizza?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8

同时确认连接池配置的 driverClassName,MySQL 8 用com.mysql.cj.jdbc.Driver,MySQL 5 用com.mysql.jdbc.Driver,两个类名不能混用。改完先停 Tomcat 再重新启动,连接池会缓存旧配置。

4.3 现象:页面数据是中文,但页面上显示乱码,或者提交中文到数据库变成问号

原因有三个层面,任何一个没配好都会乱码:JSP 文件编码不是 UTF-8、Tomcat 接收请求时没用 UTF-8 解码、MySQL 连接和表结构不是 utf8mb4。

解决:JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Tomcat 的 server.xml 里给 Connector 加URIEncoding="UTF-8";db.properties 里已经加了characterEncoding=utf8。如果改完还乱,直接去 MySQL 执行ALTER TABLE orders CONVERT TO CHARACTER SET utf8mb4;,重点看 orders 和 order_detail 这两张表,因为订单里的菜品名称是从菜品表快照过来的,最容易出现繁体字和表情符号乱码。

4.4 现象:运行时报 ClassNotFoundException 或者 NoClassDefFoundError,但项目里明明有 jar 包

原因:Maven 依赖没有打包到 WEB-INF/lib 里。很多依赖被 IDE 放在编译 classpath 里,IDEA 启动 Tomcat 时能找到,但如果你用外置 Tomcat 部署 war 包,依赖就丢了。还有一种常见情况是 pom.xml 里把某些依赖的 scope 写成了 provided,比如 mysql-connector-java,结果运行期找不到驱动。

解决:检查 pom.xml,确认 mysql 驱动、MyBatis、Spring 等依赖的 scope 都是默认的 compile;打开 Project Structure 的 Artifacts 页面,看 Output Layout 里 lib 下是否有所有依赖 jar。优先用 IDEA 直接运行本项目,不要自己手动拷贝 war 到 Tomcat,能少踩一半的坑。

4.5 现象:后台管理页面样式全丢了,点击菜单跳转到登录页

原因:DispatcherServlet 映射了/,所有请求都经过 Spring MVC,静态资源 CSS、JS 没放行会被拦截器一并拦截,后台 JSP 引用的路径也可能因为 context path 没拼对导致 404。如果点击菜单跳到登录页,说明 Session 失效或者拦截器把请求重定向了,这种情况经常发生在前后端路径不一致时。

解决:先确认 spring-mvc.xml 里有<mvc:resources mapping="/static/**" location="/static/"/>和拦截器对/static/**的 exclude-mapping。再看 JSP 里的静态资源引用,建议全部改成<%=request.getContextPath()%>/static/css/xxx.css这种写法,别写相对路径。改完清理 Tomcat work 目录再重启,浏览器强制刷新一次。

5. 二次开发思路:从跑通到能上新业务的三个改造方向

5.1 前后端分离改造:把 JSP 返回页面改成返回 JSON

当你想接一个微信小程序或者手机 H5 端时,现有 JSP 渲染方案就不够用了。改造思路是保持 Service 层和 DAO 层不动,新增一组 RestController,方法直接返回对象,Spring 会自动用 JSON 序列化。注意跨域配置要加上,否则小程序或前端域名访问会被浏览器拦截。改造前先列出所有现有 Controller 的接口,给每个接口设计一套 JSON 响应结构,比如统一code + message + data,这一步别省。

5.2 给购物车加上 Redis 持久化

Session 存购物车刷新页面还在,但用户换个设备就没了。加 Redis 的思路是:用户登录后以cart:user:{userId}为 key,用 Hash 结构存菜品 id 和数量,商品信息下单时再回查数据库。订单提交时先删 Redis 里的购物车,再写数据库,保证购物车状态和订单一致。没有 Redis 环境时,可以先换成一张 cart 临时表练手,逻辑几乎一样,只是把过期时间换成手动清理。

5.3 订单状态用状态机管理

原项目的 status 只是个 int,改状态散落在各个 Controller 里。更可靠的做法是定义一组状态和允许的流转路径,所有状态变更走同一个方法,非法流转直接抛异常。状态表可以这样设计:

状态含义可流转到
0待支付1 已支付 / 2 已取消
1已支付3 已完成 / 4 退款中
3已完成无
2已取消无

改造后还要加一个定时任务,把超过 30 分钟未支付的订单自动改成取消状态,这一步通常用 Spring 的@Scheduled扫描订单表即可。我最初接手类似项目时嫌状态机麻烦,直接在每个 Controller 里改 status,后来上线当天遇到用户取消已完成订单的 Bug,从那以后我每次接手带状态字段的项目,都强制先画一张状态流转表再动代码。这篇解析把项目从结构到代码都过了一遍,希望帮到你,也欢迎你用这份源码包做点自己的改造练手。

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

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

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

立即咨询