☰
JSP+Servlet开发外卖订餐系统:从会话管理到订单状态机实战
2026/10/1 11:00:57 网站建设 项目流程

简介:基于JSP与Servlet开发的外卖订餐系统完整项目,采用MVC分层架构,覆盖会员、骑手、商家、管理员四类角色,前台点餐与后台管理逻辑完整,适合Java Web初学者、课程设计及毕业设计开发者参考。项目压缩包约93.63MB,内含源代码、MySQL数据库脚本、账号说明文本及操作演示视频等文件,导入Eclipse并执行数据库脚本即可快速搭建运行,省去手工建表环节。系统以Servlet作为控制层负责请求分发,JSP承担页面渲染,结合数据库完成菜品浏览、购物车、订单流转、配送管理等核心业务,不同角色拥有独立权限。已有659人学习下载,通过该项目可系统掌握JSP、Servlet、MVC模式、数据库表设计、多角色权限控制等关键技能,也能为后续SSM或Spring Boot学习打下坚实基础。配套演示视频可直观了解部署与使用流程,便于参照源码逐项理解实现细节。

1. 为什么用JSP+Servlet做外卖订餐系统反而更容易入门

很多新手第一眼看到“JSP+Servlet开发外卖订餐系统”会觉得这技术栈老掉牙,现在Spring Boot满天飞,谁还碰这些?但真去带几个做毕设或者练手的同学你就知道,JSP+Servlet这套组合把HTTP请求从Tomcat进来、Servlet处理逻辑、JSP渲染页面这个过程拆得极其透明,没有任何自动配置的黑匣子。这个外卖系统覆盖会员点餐、骑手接单、商家出餐、管理员统计四个角色,正好把会话跟踪、过滤器、状态机、多表联查这些最核心的Java Web知识全部串起来了。如果你正在选毕设方向,或者想把Servlet从底层吃透,这套项目是值得认真投入的。别被“老”吓退,老技术学透了,看新框架三天就能上手。

2. IDEA新建JSP项目与数据库设计:先搭好Servlet容器的骨架

2.1 在IDEA里新建传统Web工程,而不是创建Spring Boot

以前用Eclipse建Dynamic Web Project,现在大家都用IDEA,但IDEA默认的新项目向导会把人往Spring Boot上带。做传统JSP+Servlet项目,我一般是创建Maven工程然后手工补war插件,这样最可控。在IDEA里选择Maven Archetype时选maven-archetype-webapp,或者直接建普通Maven工程后在pom.xml里把<packaging>war</packaging>写死,再补src/main/webapp目录。这里千万别点Spring Boot,因为Spring Boot内嵌Tomcat对JSP支持有限,IDE里能跑,打包后JSP找不到是常事。

pom.xml里的Servlet和JSP依赖是这样的:

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency>

这里scope=provided非常关键。Tomcat自带了Servlet和JSP的实现类,如果你把依赖打进war包里,部署时可能和Tomcat自带版本冲突,典型现象是启动报java.lang.LinkageError或者NoSuchMethodError。改成provided后,编译期有依赖,打包时排除,交给Tomcat运行时提供。JDBC驱动和数据库连接池则要正常打进去,比如mysql-connector-java和HikariCP,这两个不写provided。

2.2 Servlet生命周期与请求映射:先搞清楚两个入门概念

Servlet的生命周期不是玄学,就三个阶段:init()只在第一次请求或服务器启动时执行一次,适合初始化数据库连接池;service()处理每次请求,根据GET/POST转发到doGet和doPost;destroy()在容器关闭时执行,释放资源。很多人翻车是因为过度在doGet里做数据库查询,其实读请求数据放在doPost更安全,避免URL参数泄露到访问日志里。

请求映射有两种方式。第一种是web.xml里写<servlet-mapping>,第二种是Servlet类上用@WebServlet("/order/submit")注解。我推荐注解,少改配置。但要注意url-pattern的匹配规则:/会匹配所有路径但排除JSP,/*会匹配包括JSP在内的一切,*.do是扩展名匹配。很多新手用/*拦截所有请求,结果把JSP页面也拦截了,页面直接返回空白或者404。一个完整的Servlet模板长这样:

@WebServlet("/order/submit") public class OrderSubmitServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String orderId = req.getParameter("orderId"); // 业务逻辑 resp.sendRedirect(req.getContextPath() + "/order/list.jsp"); } }

这里注意req.setCharacterEncoding必须在读取参数之前调用,否则POST请求的中文参数全是乱码。getParameter返回的是字符串,转数字时要处理NumberFormatException,否则用户传一个非数字参数,服务器直接500。

2.3 外卖数据库表设计:四类角色加订单状态怎么落表

数据库设计是这套项目的地基。四类角色各一张表,再加菜品、订单、订单明细。最核心的是订单表,它把会员、商家、骑手全部关联起来。我给出最小可用的建表语句:

CREATE TABLE member ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password CHAR(64) NOT NULL, phone VARCHAR(20), address VARCHAR(255) ); CREATE TABLE merchant ( id INT PRIMARY KEY AUTO_INCREMENT, shop_name VARCHAR(100) NOT NULL, phone VARCHAR(20), status TINYINT DEFAULT 1 ); CREATE TABLE rider ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20), status TINYINT DEFAULT 0 COMMENT '0空闲,1配送中' ); CREATE TABLE dish ( id INT PRIMARY KEY AUTO_INCREMENT, merchant_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0 ); CREATE TABLE order_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_sn VARCHAR(32) UNIQUE, member_id INT NOT NULL, merchant_id INT NOT NULL, rider_id INT, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付,1待接单,2配送中,3已完成,4已取消', total_amount DECIMAL(10,2), create_time DATETIME, pay_time DATETIME );

注意订单表名千万别用order,那是SQL保留字,MySQL里可以用反引号包着,但所有查询都得写反引号,太痛苦,直接叫order_info省心。状态字段用TINYINT而不是字符串,好处是状态机流转时写SET status=2 WHERE status=1这种条件更新特别清晰,而且索引体积小。order_sn要设唯一索引,因为订单号需要保证并发下不重复。

3. 会员点餐到商家接单:用Servlet实现完整业务流

3.1 会员端会话购物车:用HttpSession而不是Cookie

购物车是会员端最典型的需求。有人图省事把购物车数据放Cookie里,结果很快就后悔。Cookie上限只有4KB,存几个菜品ID和数量还好,一旦加备注、规格、优惠信息就爆了,而且每次HTTP请求都要来回传递,JSON序列化转义折腾得头皮发麻。正确做法是放HttpSession里,默认30分钟过期,用户关掉浏览器购物车还在,体验更好。

我给一个加购的Servlet实现:

@WebServlet("/cart/add") public class CartAddServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); Integer dishId = Integer.valueOf(req.getParameter("dishId")); int count = Integer.parseInt(req.getParameter("count")); HttpSession session = req.getSession(); Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } cart.merge(dishId, count, Integer::sum); session.setAttribute("cart", cart); resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } }

cart.merge是Java 8给Map加的语法糖,键不存在就放初始值,存在就执行Integer::sum,把同一种菜品的数量累加。这里有个细节:dishId和count直接Integer.valueOf和parseInt,一旦前端传了异常参数就会抛异常。真实项目要包一层异常处理,返回JSON提示参数错误,但很多演示项目就这么裸写,能跑就行,你自己做的时候最好加上。

为什么用Session而不是数据库?购物车是临时数据,没下单前不需要持久化。如果每次加购都写数据库,压力全在DB上,而且用户随手关页面会产生一堆僵尸数据。Session天然跟着会话走,超时自动清理,数据库零压力。

3.2 商家接单与订单状态机:防止重复接单的乐观锁写法

订单状态机是这个系统的核心脉络。设计上最容易被忽略的点是并发。外卖场景里,一个订单同时被两个骑手看到,两个人都点“抢单”,如果代码写成“先查状态,再更新”,必翻车。两个请求都查到状态是待接单,然后都执行UPDATE order_info SET rider_id=? WHERE id=?,第二个请求会直接覆盖第一个,两个骑手都以为自己抢到了,但数据库里只有一个人。

解决办法是乐观锁,用条件更新代替“先查后改”:

String sql = "UPDATE order_info SET status=2, rider_id=? WHERE id=? AND status=1"; int rows = db.update(sql, riderId, orderId); if (rows == 0) { // 抢单失败,订单已被别人处理 }

这个SQL的意思是:只有状态还是待接单(status=1)时才能把状态改成配送中(status=2)。UPDATE返回的受影响行数rows是关键。如果rows是0,说明订单状态已经变了,这次抢单失败。不用锁表,不用事务,一行SQL就把并发问题解决了。实践中骑手抢单会频繁调用这个接口,核心就是这条批量更新语句。

商家端的接单逻辑同理。商家点击“接单”时执行UPDATE order_info SET status=1 WHERE id=? AND status=0,防止同一个订单被重复确认。整个状态机就靠WHERE里带一个当前状态条件来推进,简单有效。注意,状态流里待支付→待接单需要事务,因为要同时更新库存和状态,但待接单→配送中这种单状态变更不需要事务。

3.3 JSP页面展示数据:request转发与EL表达式的配合

有同学喜欢在Servlet里直接response.getWriter().println("<html>")拼页面,一顿操作猛如虎,改样式时想死的心都有。正确做法是Servlet只准备数据,JSP负责渲染。比如订单列表页面,Servlet里把订单集合塞进request:

req.setAttribute("orderList", orderService.listOrders(memberId)); req.getRequestDispatcher("/member/orderList.jsp").forward(req, resp);

JSP里用EL表达式和JSTL遍历:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <table> <c:forEach var="order" items="${orderList}"> <tr> <td>${order.orderSn}</td> <td> <c:choose> <c:when test="${order.status == 0}">待支付</c:when> <c:when test="${order.status == 1}">待接单</c:when> <c:otherwise>配送中</c:otherwise> </c:choose> </td> </tr> </c:forEach> </table> </body> </html>

这里要注意转发和重定向的区别。forward是服务器内部跳转,浏览器地址栏不变,request里的属性还能读到;sendRedirect是浏览器发起新请求,地址栏变,request里的数据全丢。所以setAttribute后必须用forward。如果用了sendRedirect去JSP页面,${orderList}永远是空的,这个坑特别隐蔽。

另外,JSP页面里的状态码展示推荐用c:choose而不是在Java代码里拼字符串。因为订单状态字段在数据库是数字,显示层要转成中文,把转换逻辑放JSP里可以让Servlet保持纯净。如果想省事,也可以在订单模型里加一个getStatusText()方法,直接在EL里调用。

4. 避坑与排查:权限拦截、状态并发与打包部署的翻车记录

4.1 过滤器拦掉了JSP,样式和页面一起崩了

现象:给系统加登录验证过滤器,配置了@WebFilter("/*"),结果打开登录页发现所有CSS、图片全挂,点击登录按钮后跳转的JSP页面是空白的,控制台还报404。

原因:/*真的匹配所有请求,包括/css/style.css、/login.jsp,过滤器把这些请求全部拦住往login.jsp重定向,变成了无限重定向循环。Tomcat收到循环请求后直接拒绝,表现为页面白屏或404。实际上JSP和静态资源本应由Tomcat默认Servlet处理,不需要经过权限过滤器。

解决:把你的权限校验过滤器拦截范围改小,只拦截Servlet路径。比如所有后端请求都以/api/开头,或者以.action结尾,静态资源和JSP页面放行。如果是注解式过滤,可以写成@WebFilter(urlPatterns = {"/api/*", "/servlet/*"})。如果是web.xml配置,注意filter-mapping里的url-pattern别偷懒全用/*。另外,JSP页面内部的include如果被过滤器拦截,还会出现半个页面加载出来的诡异现象,排查时先看过滤器配置。

4.2 两个骑手同时抢单,订单状态被后提交的覆盖

现象:测试环境下A、B两个骑手同时点“抢单”,两个人都看到“抢单成功”,刷新订单详情发现骑手信息是B,A不服,查日志发现A的更新也执行了,只是后更新的把前面覆盖了。

原因:就是上一章讲的“先查询后更新”问题。代码写成先SELECT status判断是待接单,再UPDATE SET rider_id=?,这两个操作之间有间隙,并发时两个请求都通过判断,都执行更新,没有条件约束,后面的覆盖前面的。

解决:把两条SQL合并成一条条件更新。UPDATE order_info SET status=2, rider_id=? WHERE id=? AND status=1,然后检查返回行数。这就是乐观锁的经典应用。如果对应项目里有冗余字段比如“抢单次数”要累加,可以写成SET confirm_count=confirm_count+1,但核心还是WHERE status=1。记住一条:状态机流转必须用条件更新,任何“先查再改”在并发面前都是裸奔。另外,如果用的是MySQL,注意事务隔离级别默认REPEATABLE_READ,但这里因为是一个原子UPDATE,不会碰到幻读问题,放心。

4.3 Nginx无法直接跑JSP,反向代理才能救你

现象:把项目打包成war部署到Tomcat,8080端口访问一切正常。但为了对外提供80端口服务,装了Nginx,配置了location / { root /opt/tomcat/webapps/xxx; },然后访问页面发现JSP内容全被原样打印出来,或者直接502。

原因:很多人以为Nginx能解析JSP。其实Nginx天生不是Servlet容器,它只能处理静态文件或者把请求转发给后端。JSP文件必须交给Tomcat这类Servlet容器翻译成Servlet再编译执行。把JSP放在Nginx的root目录里,Nginx不认识,直接把JSP源码当文本返回了。

解决:Nginx只做反向代理,把动态请求转发给Tomcat。配置大概是这样的:

server { listen 80; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这样所有请求都交给Tomcat处理,JSP能被正确解析。如果想用Nginx加速静态资源,可以单独配location ~* \.(css|js|png)$ { expires 7d; root /opt/tomcat/webapps/xxx; },但图片路径要对上Tomcat的部署目录,否则还是404。这个坑在毕设答辩演示时特别容易踩,因为很多人的电脑性能一般,开了Nginx就觉得改了配置就能跑JSP,结果白屏一脸懵。

5. 打包war与上线验证:最后再补几个保命骚操作

先用Maven把项目打成war包,这一步能暴露一半以上的环境问题。在项目根目录执行:

mvn clean package -DskipTests

生成的target/外卖订餐系统.war直接复制到Tomcat的webapps目录下,启动Tomcat就会自动解压部署。这里注意,如果你用IDEA自带的Artifacts打包,可能漏掉一些依赖;而Maven打包会按照pom.xml完整引入依赖,更稳妥。部署后别急着浏览页面,先看日志:

tail -f /opt/tomcat/logs/catalina.out

重点看有没有ClassNotFoundException和JasperException。前者多半是依赖没打进去,后者多半是JDK版本和JSP编译器不匹配。Tomcat 9用JDK 8或11都行,但你要是用JDK 17,部分老版本Tomcat会直接崩溃,建议Tomcat 9.0.50以上配JDK 11。

验证接口时我习惯用curl而不是点浏览器,因为能看状态码和响应头,定位问题更快:

curl -X POST "http://localhost:8080/order/submit" -d "orderId=1001&memberId=8"

如果返回乱码,检查两处:JSP页面pageEncoding="UTF-8",以及Tomcat的server.xml里URIEncoding="UTF-8"。这基本是中文乱码的万能解药。最后提一句,线上部署时一定要关闭JSP的自动编译开关,也就是在web.xml里把development设为false,否则每次访问页面都会检查有没有更新,性能损耗肉眼可见。我把这些踩坑经验放在手边,每次重做项目都少折腾半天,希望帮到你。

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

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

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

立即咨询