简介:基于JSP+Servlet开发的外卖订餐系统项目,面向正在学习Java Web的初学者以及需要搭建多角色业务系统的开发者。项目采用经典MVC模式,完整实现会员、骑手、商家、管理员四种角色的核心功能,覆盖用户点餐、订单配送、商品管理、后台监控等典型流程,能清晰展示Servlet控制层与JSP视图层的协作方式,同时提供MySQL数据库设计思路,适合作为课程设计或毕业设计的参考方案。资源压缩包大小约93.63MB,内置源代码、数据库脚本、操作演示视频及账号说明,导入Eclipse并配置好数据库环境即可运行,便于边看边学。目前已有659人学习,通过该完整项目实践,可以系统掌握JSP、Servlet、MVC分层架构、MySQL数据操作等关键技能,对提升Web开发能力具有切实帮助。
1. 一套外卖订餐系统把 JSP、Servlet 与四角色权限一起说清
很多教程把 JSP+Servlet 当成“过时组合”一笔带过,但对外卖订餐(点餐)这种经典多角色项目来说,这个组合至今是理解 Java Web 底层请求-响应模型的捷径。会员看菜单、商家管订单、骑手抢单、管理员看统计,四个角色共享同一套订单数据,却要看到完全不同的页面和操作按钮——把权限边界和状态流转用 JSP+Servlet 写明白,比背面试八股更管用。这套系统的难点不在于某个算法,而在于角色会话如何隔离、订单状态如何防重、库存如何一致性扣减。下面从表设计开始拆,给出能直接照做的代码和参数调整方法,适合正在做课程设计的人和刚接触 Java Web 服务的开发者在本地跑通,并理解每一步为什么要这么落。
2. 四角色权限边界设计:JSP+Servlet 的表结构与 Session 鉴权方案
在开始写第一行 Servlet 代码前,先要解决一个问题:四个角色在一个部署包里如何共存。这里的角色不只是控制按钮显隐的前端开关,而是数据行级别的归属——订单数据由谁创建、由谁修改、由谁可见,都要在代码层明确出来。角色设计一旦模糊,后面每个功能都会靠临时补if roleType == 1这种分支撑住,越改越乱。
2.1 user 表的角色字段与订单关联表的最小设计
常见做法是只建一张 user 表,用一个 role 字段区分四种身份。这个方案本身没错,但后续 shop、delivery、order_info 之间的关系要通过外键连接。我一般会在 user 表上保留id、username、password、role_type、status五个核心字段,其中 role_type 取 1 到 4,分别对应会员、骑手、商家、管理员。这样登录后一次查询就能拿到用户身份,JSP 页面上可以按角色输出不同入口,不需要查角色表再做关联。
业务表方面,按“订单数据为主线”的方式拆,外卖订餐系统的最小表集合如下:
| 表名 | 关键字段 | 作用 |
|---|---|---|
| user | id, username, password, role_type, status | 全局账号,status 控制禁用 |
| shop | id, owner_id, name, address, phone | owner_id 关联 user,商家与店铺一对一 |
| order_info | id, order_no, user_id, shop_id, rider_id, status, amount, create_time | 订单主表,骑手字段允许为空 |
| order_item | id, order_id, food_id, food_name, price, quantity | 订单明细,保存下单时的价格快照 |
order_item单独拆表而不是往 order_info 里塞文本字段,是为了后续统计营业额时能按商品维度聚合。外卖订餐系统里最容易被忽略的是价格快照——商家改价后,历史订单仍要显示顾客下单那一刻的金额,所以插入 order_item 时要把 food_name 和 price 一并冗余进去。rider_id放在 order_info 上意味着同一骑手可以同时持有多个配送中订单,代码里判断骑手是否还能再接单,需要 count 当前 status 处于配送中的订单数。
2.2 用 HttpSession 存身份,用 Filter 做统一鉴权
四个角色共用一个登录入口,登录成功后把 userId 和 roleType 写进 Session。后续每个 Servlet 不需要各自判断“是否已登录”,而是由一个 Filter 统一拦截。Servlet 3.0 以后可以用 @WebFilter 注解,省去 web.xml 里的 filter-class 配置:
@WebFilter("/*") public class AuthFilter implements Filter { private static final List<String> WHITE_LIST = Arrays.asList( "/login", "/login.jsp", "/register.jsp", "/static/"); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 白名单路径直接放行,其余全部检查会话 for (String prefix : WHITE_LIST) { if (uri.startsWith(prefix)) { chain.doFilter(req, resp); return; } } HttpSession session = request.getSession(false); if (session == null || session.getAttribute("userId") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 把角色放入请求属性,JSP 页面用 requestScope 读取 request.setAttribute("currentRole", session.getAttribute("roleType")); chain.doFilter(req, resp); } }使用getSession(false)而不是getSession(),是为了避免为非法请求新建无用会话;当用户未登录且访问受保护资源时,直接重定向即可。把 roleType 从 Session 复制到 request 属性,是为了在 JSP 里统一使用 requestScope,避免同一页面到处出现 session.getAttribute 强转。购物车数据也放在 Session 中时,注意与用户身份使用不同 key,二者互不覆盖。
2.3 Session 失效、并发登录与开发期排查手段
默认 Tomcat 的 Session 超时是 30 分钟,会员长时间停留后下单,Session 中没了 userId,Servlet 拿到的就是 null。代码里对这个值必须做判空,否则下一步按 userId 查询订单时会出现空指针或 SQL 参数缺失。另一个常见问题是同一账号多处登录:商家在前台挂着,另一台设备又登录,两个 Session 会互相覆盖。可以对账号维护最近一次 login_token,Filter 每次和数据库比对,不一致的请求直接跳登录页,这在骑手场景里很有用,避免一台设备退出后另一台仍在操作。
开发阶段想看 Session 内容,不必每次都断点调试,写一个临时的 DebugServlet 打印所有属性名和值,访问后直接看输出即可。排查角色权限问题时,最快路径是确认 Filter 里 WHITE_LIST 的路径是不是被业务 Servlet 占用了,比如把 /loginServlet 也放进白名单,会造成绕过登录直接开订单查询接口的风险。
3. 会员点餐与商家接单:JSP+Servlet 的订单链路和状态流转实现
权限和数据模型铺好后,核心业务链路从会员选菜开始,到商家确认接单为止:选菜入购物车、提交订单、生成订单明细、商家看到待接单列表、修改订单状态。这一段把 JSP 页面、Servlet 逻辑、数据库操作串在了一起。
3.1 新建项目时如何配置 JSP 与 Servlet 的目录关系
项目结构不要把所有 JSP 堆在 webapp 根目录。常见做法是 src/main/java 下放 Servlet、Service、Dao;src/main/webapp 按角色分目录:member/、shop/、rider/、admin/,公共组件放 common/。在 IDEA 里创建 Maven Java Web 项目时,选择 Web 模板后手动加一个 Tomcat 运行配置即可;Eclipse 里则需要把项目 Facet 改成 Dynamic Web Module 并指定 WebContent 目录。
Servlet 的 URL 映射要保持简洁,和 JSP 文件路径尽量一致:
<servlet> <servlet-name>OrderServlet</servlet-name> <servlet-class>com.food.order.OrderServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>OrderServlet</servlet-name> <url-pattern>/order</url-pattern> </servlet-mapping>把 /order 映射给 OrderServlet 后,JSP 里的表单 action 写成 /order?action=create 这样的形式,Servlet 在 doPost 里按 action 分发。这里用精确匹配而不是 /* 通配,目的是让静态图片、CSS、JS 不经过业务 Servlet,避免资源请求被鉴权拦截后返回登录页。以后迁到 Spring MVC 时,DispatcherServlet 的路径映射思路一脉相承,差别只在注解和自动配置上。
3.2 菜单展示与购物车的 Session 实现
会员进入店铺后,由 MenuServlet 查出该店铺所有菜品并转发到 menu.jsp。购物车不落库,用 Session 里的 Map 保存,结构为 Map<foodId, quantity>。接口里的增删操作合并成一个 delta 参数,由同一个 CartServlet 处理:
@WebServlet("/cart") public class CartServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Integer foodId = Integer.valueOf(req.getParameter("foodId")); int delta = Integer.parseInt(req.getParameter("delta")); HttpSession session = req.getSession(); Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } // delta 为 1 或 -1,统一处理加购和减购 int count = cart.getOrDefault(foodId, 0) + delta; if (count <= 0) { cart.remove(foodId); } else { cart.put(foodId, count); } session.setAttribute("cart", cart); // 返回精简JSON,前端 fetch 更新购物车角标 resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":0,\"total\":" + cart.size() + "}"); } }代码里用 delta 的正负号控制加购与减购,减少了分支和重复代码;getOrDefault 避免先 containsKey 再 get 两步判断。返回 JSON 而不是整页刷新,是让用户在菜单页直接更新角标数量。如果不想手动拼 JSON,可以引入 fastjson 或 Jackson,但纯 JSP 项目引入外置 jar 时,不要在 JSP 的 scriptlet 里 import JSONArray 再去拼字符串,页面转义很容易出问题,更稳妥的是前端用原生 JSON.parse 接收响应。
购物车确认页展示时,要根据购物车里的 foodId 重新回表查菜价,不能直接信任 Session 里上次加载的价格。商家可能在用户浏览期间改了价,提交订单时必须重新读库校验,真实业务里这一步不能省;否则会出现页面显示 20 元、结账按 25 元或相反的情况。
3.3 订单状态机与商家“接单”操作的并发安全
订单状态不能只存一个中文描述,也不要在 JSP 里用 if 判断后直接 update。完整状态至少包含待接单、已接单、配送中、已完成、已取消,退款场景再加退款中。状态迁移规则:
| 当前状态 | 可执行操作 | 目标状态 |
|---|---|---|
| 待接单 1 | 商家接单 | 已接单 2 |
| 待接单 1 | 商家超时未接 | 已取消 5 |
| 已接单 2 | 骑手抢单 | 配送中 3 |
| 配送中 3 | 骑手确认送达 | 已完成 4 |
| 已完成 4 | 会员申请退款 | 退款中 6 |
状态迁移用条件更新(CAS)比先查后改更可靠。先查后改在并发下会出现两个请求都读到 status=1,然后都执行 update,最后谁改成功取决于执行顺序。正确做法是把校验写进 SQL:
UPDATE order_info SET status = 2, accept_time = NOW() WHERE id = 1001 AND status = 1;受影响行数为 0 时,说明订单已被其他请求处理,代码中要返回“订单状态已变化”,不要再继续后续业务。单独一条 UPDATE 在 MySQL 中本身就是原子的,autocommit 为 true 时不需要额外事务;但如果接单后还要更新商家统计表或写入操作日志,就需要用事务包住,否则会出现订单状态变了日志没写上的中间态。
4. 骑手配送与管理后台:Servlet 实现订单调度与统计模块
订单流转到“已接单”后,骑手角色登场。骑手端核心页面是待抢单列表和我的配送列表,后者有“确认送达”按钮。管理后台则要面对全量订单、会员和商家的跨角色数据。这个模块最终要做到抢单不重复、配送状态可回溯、统计口径一致。
4.1 骑手抢单的幂等处理与并发冲突
抢单是天然的高并发场景:多个骑手同时刷新同一批待接订单,点击抢单时同时发起 update。如果不做幂等,前端再限制按钮都没用,数据库层面的条件更新才是最后的防线:
@WebServlet("/rider/accept") public class RiderAcceptServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { Integer orderId = Integer.valueOf(req.getParameter("orderId")); Integer riderId = (Integer) req.getSession().getAttribute("userId"); String sql = "UPDATE order_info SET rider_id = ?, status = 3, accept_time = NOW() " + "WHERE id = ? AND status = 2 AND rider_id IS NULL"; int rows = DBUtil.update(sql, riderId, orderId); if (rows == 1) { resp.getWriter().write("SUCCESS"); } else { resp.getWriter().write("ORDER_TAKEN"); } } }这段 SQL 同时约束 status=2 和 rider_id IS NULL,两个条件缺一不可。status=2 保证订单确实处于可抢状态,rider_id IS NULL 保证还没有人抢先。两条并发 update 在 MySQL 中按行锁依次执行,只有一个能影响一行,另一个受影响行数为 0,从根上杜绝了重复抢单。
骑手每日接单上限的判断不能用“先 select count 再 update”实现,因为并发下两个请求可能同时查到低于上限再 update。对小型项目,正确做法是在同一个事务里先SELECT ... FOR UPDATE锁定骑手统计行,再更新订单,不加 Redis 分布式锁也能保证正确。唯一代价是这行记录在同一时刻只允许一个事务修改。
4.2 管理员后台的订单查询与统计封装
管理员模块不参与具体业务写操作,重点在全局查询和异常订单处置,常见功能是会员管理、商家管理、骑手管理、订单列表、每日营收。这些页面共用一套分页逻辑,建议把分页封装成 PageBean 对象,业务查询只提供 SQL 和查询参数,分页参数 currentPage、pageSize 由前端传入。JSP 底部翻页按钮用 form 提交页码参数即可,不需要引入前端框架。
管理员页面经常引用 JSTL 标签库,一旦 tld 文件路径或 tag 前缀不一致,JSP 会在首次访问时报 500。这类问题不要靠猜,去看 Tomcat work 目录下对应 JSP 的编译产物,报错行会直接指向标签库解析失败的位置。下单金额统计最容易栽在时区上:MySQL 的 datetime 不带时区,JDBC 连接串不指定 serverTimezone 时,Tomcat 主机时间和数据库时间相差 8 小时,日终报表会错位。连接 URL 习惯性补上:
jdbc:mysql://localhost:3306/food?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/ShanghaiuseUnicode 和 characterEncoding 保证中文菜名不乱码,serverTimezone 让 JDBC 驱动按东八区解析 datetime,否则前一天的订单会被算进今天的营业额。
4.3 下单与扣库存必须放到同一个事务里
外卖订餐系统如果要做“每日限量”功能,会员下单时既要插入订单,又要扣减库存,这两步必须同生共死。用 JDBC 写事务时,我一般通过 ThreadLocal 保存当前线程的 Connection,让同一个请求内所有 DAO 共用一个连接:
public class DBUtil { private static final ThreadLocal<Connection> HOLDER = new ThreadLocal<>(); public static Connection getConnection() throws SQLException { Connection conn = HOLDER.get(); if (conn == null || conn.isClosed()) { conn = DriverManager.getConnection(URL, USER, PASS); conn.setAutoCommit(false); // 事务提交交给上层 HOLDER.set(conn); } return conn; } public static void rollbackQuietly() { try { Connection conn = HOLDER.get(); if (conn != null) { conn.rollback(); } } catch (SQLException ignored) { } } }关键点是conn.setAutoCommit(false)和把连接放入 ThreadLocal。设置 autoCommit(false) 后,DAO 里的每一条 SQL 都不会立刻提交,直到控制器层在所有操作完成后显式 commit。这样创建订单、插入明细、扣减库存、清空购物车四个动作中任何一步抛异常,都可以 rollback,不会留下“订单已创建但库存没扣”的脏数据。
要注意的是 Filter 拦截的多个 Servlet 可能复用同一个 Tomcat 线程,ThreadLocal 中会残留上一次请求的连接,请求结束时必须在 finally 里 close 连接并调用 HOLDER.remove()。这一点处理不到位,系统运行一段时间就会出现连接耗尽,Tomcat 日志里持续报连接超时,而代码逻辑看不出任何问题。
5. 上线前必查的 JSP / Servlet 运行细节与参数调整
5.1 修改 JSP 不生效时先看 work 目录
JSP 首次被访问会被 Jasper 引擎编译成 Servlet,class 文件默认放在 Tomcat 安装目录的work/Catalina/localhost/项目名/org/apache/jsp/下。修改 JSP 后页面没变化,先去看这个目录里对应 class 的修改时间。时间没变通常是浏览器缓存了页面,刷新并清空缓存即可;时间比 JSP 新还在用老内容,则要重启 Tomcat 强制清理 work 目录。IDEA 部署到外部 Tomcat 时,work 路径由 catalina.base 决定,可通过System.getProperty("catalina.base")打印确认。从 JSP 编译产物反查 HTML 输出问题时,比在浏览器里右键查看源码更接近出错根因。
5.2 屏蔽 JSP 离开页面提示与表单重复提交
Post 提交后点击浏览器返回,会出现“确认重新提交表单”的提示。要从根上消除,Servlet 处理完写操作后执行 sendRedirect 跳转到新的 GET 页面,这是 PRG(Post/Redirect/Get)模式在 JSP 里的标准实现。如果只想在 JS 层面拦截离开,可以在页面注册 beforeunload 事件,但不要给所有页面统一挂,只对编辑未保存内容的页面启用。购物车在订单创建成功后要及时清空或标记状态,否则用户回退页面再次提交,会产生重复订单;正确做法是提交前生成唯一订单号,提交时用订单号做唯一索引约束,从数据库层面挡住重复插入。
5.3 启动报错的两种典型原因
Eclipse 从旧工程导入或从压缩包解压再部署时,常见报错是NoClassDefFoundError,比如启动类里引用了 SpringBootServletInitializer,但依赖中没有对应 jar,或 javax.servlet-api 被项目依赖重复打进包里。处理方式是给 servlet-api 相关依赖加 provided 作用域,让 jar 不进入最终的 WEB-INF/lib,从而避免和 Tomcat 自带的 Servlet API 冲突。另一个容易混淆的点:纯 JSP+Servlet 项目不需要 DispatcherServlet,也不需要配置 RootWebApplicationContext,更不需要在 web.xml 里声明 Spring 的监听器,一个普通 HttpServlet 加 web.xml 就足够处理所有请求。
本文还有配套的精品资源,点击获取