简介资源为基于JSPServlet开发的外卖订餐系统完整项目采用MVC分层架构覆盖会员、骑手、商家、管理员四类角色适合正在学习Java Web、课程设计或毕业设计的开发者使用。项目基于Eclipse开发后台Servlet处理请求JSP负责动态页面展示MySQL存储订单、用户、商品等核心数据完整呈现从登录鉴权到业务数据落地的开发链路。压缩包约93.63MB包含项目源代码、MySQL建库脚本online.sql、账号说明、系统截图及WMV格式运行演示视频素材覆盖环境配置、界面展示与功能操作。资源已有659人学习代码结构清晰、业务逻辑完整可帮助读者快速掌握JSPServletMySQL的集成开发方式。通过对照源码与演示视频可深入理解订单流转、角色权限、商家后台、骑手接单等模块的实现思路也可直接作为课程设计或二次开发的基础工程。1. 基于JSPServlet的外卖订餐系统值不值得做先想清楚这四个角色每年到了毕设选题季“基于JSP的毕设选题”里总有一批外卖订餐系统标题十有八九长这样会员、骑手、商家、管理员四个角色齐全。很多人第一反应是“JSPServlet是不是太老了”但真上手你会发现这个组合反而是把 JavaWeb 基础课内容覆盖得最全的方向——Servlet生命周期、Cookie/Session、Filter 拦截器、JDBC 手写 SQL、分页、事务、war 包部署一个项目全练到。对于课程设计、本科毕设、或者刚学完 JavaWeb 想找个完整项目练手的人来说这套技术栈比 Spring Boot 全家桶更能让你说清楚“请求到底是怎么被处理的”。我接下来说的就是按这个标题做一个可演示、能答辩的外卖订餐系统时最常走的一条路。2. 先把四类角色的权限边界画清楚从登录态到数据表设计2.1 会员端要做什么从菜品浏览到订单催单的最小闭环会员端是整个系统的流量入口也是演示时最先被点开的部分。一个能拿得出手的会员端至少要覆盖这样一条路径注册登录 → 按分类浏览菜品 → 加入购物车 → 确认下单 → 查看订单状态 → 催单或取消。中间不需要做社交、积分、优惠券这类锦上添花的功能把高频动作做完整就够了。在 JSPServlet 的实现方式下会员端通常是一组 JSP 页面配一组 Servlet。JSP 负责展示Servlet 负责接收请求、调 DAO、把结果塞进 request 作用域再转发回 JSP。常见做法是每个业务动作一个 Servlet比如LoginServlet、CartServlet、OrderServlet。页面结构大致是index.jsp菜品列表、cart.jsp购物车、order_list.jsp我的订单、profile.jsp个人信息展示页面配一个用户信息修改表单。这里的“个人信息展示页面”是热词里出现过的点我会把它做成一个独立的 JSP用 EL 表达式${sessionScope.user.username}这类方式回显登录态里的用户对象比在 JSP 里写 Java 代码清爽得多。会员端的核心逻辑不在页面而在购物车。购物车放 Session 里是最省事的方案用户没登录也能加购物车登录后在提交订单那一刻再把购物车数据落库。Session 里的购物车结构我一般用MapInteger, Integerkey 是菜品 idvalue 是数量简单直接不用额外建购物车表。2.2 商家、骑手、管理员如何不越权会话管理与角色标识四角色系统最常见的问题是“登录进去乱串门”——会员能打开商家后台骑手能改订单价格。这个问题从根源上说是权限拦截没做。传统 JSPServlet 项目里权限控制的标准做法是三层配合登录时在 Session 里存用户的角色标识Filter 拦截所有受保护路径然后在每个角色对应的 Servlet 入口再校验一次 Session 里的角色。角色标识我建议用数字常量而不是字符串。1是会员2是商家3是骑手4是管理员。原因很实际字符串容易在 JSP 里写错大小写数字比较起来不出错。登录成功后 Session 里至少存两个东西用户对象user和角色值roleId。页面上的“个人中心入口”要不要显示、显示成什么样都由 JSP 页面里的c:if test${sessionScope.roleId 2}这行控制。核心的防越权动作在 Filter 里做每个请求进来先看请求路径是不是受保护的资源再看 Session 里有没有登录标识最后看这个角色允不允许访问这段路径。这一层是系统的第一道门Servlet 里的判断是第二道门两道都用上答辩时老师问“怎么防止商家访问会员接口”你才有得讲。2.3 数据表设计订单状态机是整套系统的脊梁外卖订餐系统的表数量控制在 8 到 10 张比较合适太少撑不起四角色的功能太多表关系会让毕设的工作量失控。我一般这样拆用户表含角色字段、菜品分类表、菜品表、购物车表可选、订单表、订单明细表、地址表、配送记录表。订单表是这套系统的核心它的状态字段决定了整个业务流的走向。状态值不要用随意字符串用 int 定义清晰的状态机。我惯用的状态定义如下状态值含义谁可以触发下一步0已提交待商家接单商家1商家已接单待骑手取餐骑手2骑手已取餐配送中骑手3已送达待用户确认用户4已完成系统/用户-1已取消用户/商家订单明细表单独建一张为什么因为订单表里如果只存一个“菜品名总价”的冗余字段商家看订单时只能看到名字看不到当时的价格快照。菜品的价格会变但订单一旦生成每道菜的单价必须固定下来这就是订单明细表存在的意义。建表 SQL 里要注意两个坑一是订单表的外键不要真的加物理约束理由放到第 5 章细说二是金额字段用DECIMAL(10,2)而不是FLOAT用浮点算钱在 Java 的double上会出现 0.1 0.2 ! 0.3 的经典问题做财务相关字段没人愿意为了这个翻车。3. 用 IDEA 新建 JSPServlet 项目并跑通第一个分页查询3.1 IDEA 新建 JSP 项目的三种方式与选型在 IDEA 里新建 JSPServlet 项目常见做法有三个用 IDEA 自带的 Java Enterprise 模板直接建、从 Maven 的maven-archetype-webapp原型建、或者直接建普通 Java 项目再手动添加 Web 支持。我强烈建议用第二种Maven 原型建出来的项目自带pom.xml和标准 webapp 目录结构后面引依赖、打 war 包都顺路。操作路径是这样的IDEA 里File - New - Project - Maven Archetype选org.apache.maven.archetypes:maven-archetype-webappGroupId 写com.exampleArtifactId 写takeout。项目生成后你会看到src/main/webapp/WEB-INF/web.xml和src/main/java目录。注意一件事java目录在 webapp 骨架下是空的你要手动把它标记为 Sources Root否则新建 Servlet 类时会提示路径不对。有人问在 VSCode 里写 Servlet 怎么配环境我的建议是别折腾了。VSCode 写 Servlet 要自己下 Tomcat 插件、手动配 classpath、把servlet-api.jar加到编译路径里每一步都得手搓遇到报错排查成本远高于 IDEA。这条路适合你已经很熟 Tomcat 部署机制之后再探索新手直接用 IDEA 配 Tomcat 是最稳的。pom.xml 里加依赖的方式如下dependencies !-- Servlet API编译期需要运行时由 Tomcat 提供 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP 标准标签库页面里做条件判断和循环靠它 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies依赖说明里有个容易忽略的点servlet-api的 scope 必须是provided。因为 Tomcat 容器里本身就带servlet-api.jar如果打成 war 包含进这个包部署到 Tomcat 的lib目录后会出现类加载冲突轻则报警告重则启动失败。JSTL 必须有不然 JSP 页面里写c:forEach标签会直接报错这就是很多老项目改用 JSP 写% %的原因之一。3.2 Servlet 生命周期在请求处理中是怎么执行的Servlet 生命周期是 jsp 入门阶段必考的概念也是答辩时老师大概率会问的点。一个 Servlet 从被容器加载到被销毁一共四个阶段加载与实例化、初始化init、服务service、销毁destroy。Tomcat 启动时不会立即创建所有 Servlet 实例而是采用懒加载策略。默认情况下第一次请求到达某个 URL 时Tomcat 才会加载对应的 Servlet 类、调用构造方法创建实例、然后调用init()方法做初始化。实例在整个生命周期里只创建一次后续所有请求都复用它。service()方法在每次请求都会执行它做的事情是先判断 HTTP 请求方法是 GET 还是 POST再分别转发给doGet()或doPost()去处理。所以你在写 Servlet 时通常只需要重写doGet和doPost两个方法有时只写一个doPost然后在doGet里调doPost统一入口。init()里适合做什么适合做只初始化一次的资源比如数据库连接池、读取配置文件。destroy()里适合释放资源比如关闭连接。不过在实际的 JSPServlet 项目里数据库连接一般不用在init里手动管理而是直接用 JDBC 的DriverManager在 DAO 层获取连接简单项目够用就好不用强行上连接池增加学习成本。写一个最简 Servlet 的代码骨架WebServlet(/hello) public class HelloServlet extends HttpServlet { // 生命周期实例化 - init - service - destroy Override public void init() { System.out.println(初始化一次通常在这里加载配置); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 处理 GET 请求设置编码输出响应 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); resp.getWriter().write(h1Hello, JSPServlet/h1); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { doGet(req, resp); } Override public void destroy() { System.out.println(销毁时释放资源); } }注意WebServlet(/hello)是 Servlet 3.0 之后提供的注解方式不需要在web.xml里再配置servlet-mapping。但如果你的项目里还保留了web.xml并且里面没有配metadata-complete注解依然生效。老项目用web.xml配置的好处是路径映射集中在一个文件里好管理注解的好处是所见即所得按 Servlet 类就能看到它负责哪个路径。毕设项目建议全部用注解少配一个文件少一个坑。3.3 用 JDBC 手写菜品分页参数与 SQL 的写法分页是每一个管理系统都躲不掉的功能外卖系统的菜品列表、订单列表全都要分页。Spring Boot 项目里一套 PageHelper 就解决了但在 JSPServlet 项目里你要手动写分页逻辑这反而是好处——面试和答辩时你能把 limit、pageNum、pageSize 这些参数的组合规则说清楚。分页的 SQL 固定是LIMIT offset, sizeoffset (pageNum - 1) * pageSize。核心是前端传pageNum后端定义一个默认的pageSize8。菜品列表页还有分类筛选所以 SQL 里要加WHERE category_id ?。分页查询的 Servlet 实现如下WebServlet(/dish/list) public class DishListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); // 1. 接收页码和分类参数页码默认第 1 页 int pageNum 1; String pageNumStr req.getParameter(pageNum); if (pageNumStr ! null !pageNumStr.isEmpty()) { pageNum Integer.parseInt(pageNumStr); } int pageSize 8; String categoryIdStr req.getParameter(categoryId); Integer categoryId null; if (categoryIdStr ! null !categoryIdStr.isEmpty()) { categoryId Integer.valueOf(categoryIdStr); } // 2. 调 DAO 拿分页数据与总记录数 DishDao dishDao new DishDao(); ListDish dishList dishDao.findByPage(categoryId, pageNum, pageSize); int totalCount dishDao.countByCategory(categoryId); int totalPages (int) Math.ceil(totalCount * 1.0 / pageSize); // 3. 数据塞进 request转发给 JSP 渲染 req.setAttribute(dishList, dishList); req.setAttribute(totalPages, totalPages); req.setAttribute(pageNum, pageNum); req.setAttribute(categoryId, categoryId); req.getRequestDispatcher(/jsp/dish_list.jsp).forward(req, resp); } }DAO 层对应的方法public ListDish findByPage(Integer categoryId, int pageNum, int pageSize) { ListDish result new ArrayList(); // offset第 1 页的 offset 是 0第 2 页是 8 int offset (pageNum - 1) * pageSize; String sql SELECT dish_id, dish_name, price, image_url FROM dish ; if (categoryId ! null) { sql WHERE category_id ? ; } sql ORDER BY dish_id LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { int index 1; if (categoryId ! null) { ps.setInt(index, categoryId); } ps.setInt(index, offset); ps.setInt(index, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Dish d new Dish(); d.setDishId(rs.getInt(dish_id)); d.setDishName(rs.getString(dish_name)); d.setPrice(rs.getBigDecimal(price)); d.setImageUrl(rs.getString(image_url)); result.add(d); } } } catch (SQLException e) { e.printStackTrace(); } return result; }两个参数细节PreparedStatement的参数下标从 1 开始动态拼接的 SQL 必须先数清楚有没有 categoryId再决定参数是从 1 还是 2 开始编号这是新手最容易踩的坑报错信息往往是Parameter index out of range。另一个是LIMIT ?里的占位符在 MySQL 的 PreparedStatement 里是允许的但如果换到 SQL Server 就要用OFFSET FETCH语法老项目都是从 MySQL 起步的别混用。4. 下单到配送的核心链路订单状态与三端联动4.1 下单接口购物车数据落地与事务控制下单是整个系统里最需要严谨处理的操作。用户从购物车点击“提交订单”后端要做的事按顺序来读取 Session 里的购物车数据 → 校验菜品是否还在售、库存是否够 → 计算出总价 → 插入订单表 → 插入订单明细表 → 清空购物车 → 跳转到订单详情页。前面四步任何一步失败数据都不能只写一半这就是事务要解决的问题。JDBC 的事务默认是自动提交的也就是每执行一条 SQL 就立即生效。所以手动控制事务必须关掉自动提交用connection.setAutoCommit(false)全部执行成功后commit()中间异常则回滚rollback()。这个顺序不能反先关自动提交再执行 SQL最后提交或回滚。很多人的事务看着写了结果commit放在finally里异常时也提交了那事务等于白写。下单核心代码WebServlet(/order/create) public class OrderCreateServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); // 购物车在 Session 中key 是菜品 idvalue 是数量 MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null || cart.isEmpty()) { resp.sendRedirect(req.getContextPath() /cart.jsp?errorempty); return; } ShoppingCart cartService new ShoppingCart(); Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 开启手动事务 int orderId cartService.createOrder(conn, (User) session.getAttribute(user), cart); cartService.createOrderItems(conn, orderId, cart); cartService.deductStock(conn, cart); conn.commit(); // 全部成功才提交 session.removeAttribute(cart); // 下单成功清空购物车 resp.sendRedirect(req.getContextPath() /order/detail?orderId orderId); } catch (SQLException e) { try { conn.rollback(); // 任何一个环节出错全部回滚 } catch (SQLException ex) { ex.printStackTrace(); } resp.sendRedirect(req.getContextPath() /cart.jsp?errorcreate_failed); } finally { DBUtil.close(conn); } } }这里要特别说明一个血泪经验事务里用到的Connection必须从头到尾是同一个。新手容易在createOrder和createOrderItems两个方法里各自调DBUtil.getConnection()结果就是两个独立事务第一个成功提交了第二个失败回滚第一个也就救不回来了。正确做法是把Connection作为参数传给 DAO 方法或者用 ThreadLocal 绑定当前线程的连接毕设项目用参数传递最简单好懂也好答辩。库存扣减的 SQL 也有讲究。不要先查库存判断够不够再 UPDATE两个操作之间有并发窗口。正确的是直接用一条带条件的 UPDATEUPDATE dish SET stock stock - 1 WHERE dish_id ? AND stock 0然后看受影响的行数返回 0 就说明库存不足。这个方案叫乐观锁的简单形态不用额外加版本号字段也能挡住大部分超卖场景。4.2 商家接单、骑手取餐、用户确认的流转实现订单状态从一个值流转到下一个值本质上就是一次UPDATE操作但关键点在于每一步都要校验“当前状态是不是我该操作的状态”。比如骑手点“确认送达”时如果订单还在“商家已接单”状态直接更新就会把流程打乱。所以每一步的状态更新 SQL 都要带上“当前状态”条件UPDATE order SET status 1, accept_time NOW() WHERE order_id ? AND status 0这条 SQL 的含义是只有订单处于 0待接单状态时商家才能把它改成 1已接单。如果受影响的行数为 0说明订单已经不是待接单状态——要么被别的商家抢过了要么用户取消了。服务端拿到受影响行数为 0 时要给前端一个明确的提示“订单状态已变化请刷新页面”。这套写法同步解决了并发场景下两个商家同时接一单的问题数据库的锁机制会保证同一时间只有一个 UPDATE 能成功。骑手端的订单列表和商家端的接单列表本质上是同一张订单表按状态字段查询ListOrder list orderDao.findByStatus(2); // 骑手查看所有待取餐的订单 ListOrder list orderDao.findByStatus(0); // 商家查看所有待接单的订单配送记录的轨迹可以用一张delivery_log表来记录每个状态变化都插入一条日志操作人角色、操作人 id、订单 id、动作描述、时间。耦合状态更新一起写在同一个事务里这样用户端“催单”功能才有数据可依赖。用户点“催单”时不改状态只往order_message表插入一条催单记录商家端和骑手端的 JSP 页面用一个c:if判断有没有未读催单消息有的就显示红色提醒。这比用 WebSocket 做实时推送简单得多也够演示用。4.3 用 Filter 做登录校验与角色权限拦截权限拦截的实现方式前面提过一次这里把 Filter 的完整代码补上。Filter 在 Servlet 规范里的执行时机是请求进容器 → 先经过 FilterChain → 再进 Servlet。用这个特性把登录校验和角色匹配放在 Filter 里就能做到“先拦人再干活”。登录拦截 Filter 的典型写法WebFilter(/admin/*) public class AdminAuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); // 不主动创建新会话 // 1. 会话里没有用户说明没登录跳回登录页 if (session null || session.getAttribute(user) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } // 2. 登录了但角色不是管理员4禁止进入 Integer roleId (Integer) session.getAttribute(roleId); if (roleId null || roleId ! 4) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } // 3. 校验通过放行到后续资源 chain.doFilter(request, response); } }Filter 路径映射我用WebFilter(/admin/*)这种方式效果是把所有以/admin/开头的请求都拦下来过滤。代码里有三个细节值得展开。session.getAttribute(user)如果不去取、而是直接用req.getSession()那没登录的用户访问时容器会新建一个 Session白白浪费内存而且getSession(false)在无会话时返回 null可以直接判定未登录。第二个细节是角色值的判断用int的!比较前提是存的时候存的就是Integer如果你不小心在 Session 里存了字符串4这里会一直返回 false。第三个细节是返回 403 用resp.sendError而不是重定向后者会让用户跳到一个不存在的页面体验更差。通常这个 Filter 不止一个。我会按角色拆多个 Filter用户端路径/user/*套用户登录 Filter商家端/merchant/*套商家权限 Filter骑手端/rider/*套骑手权限 Filter管理员端/admin/*套管理员权限 Filter。公共的登录校验逻辑可以抽一层父类子类只需设置允许的 roleId。这样写完答辩时间老师问权限设计你能从 Session 存储、Filter 拦截、Servlet 二次校验三个层面去讲深度立刻就出来了。5. 避坑指南从中文乱码到并发扣库存的五个高频翻车点5.1 中文乱码到底乱在哪一层现象页面上的菜名显示成家常å°ç这种乱码或者数据库里存的中文变成问号。原因JSPServlet 项目的乱码有四个可能源头按出现频率排JSP 页面本身没设 UTF-8、Servlet 里没调req.setCharacterEncoding(UTF-8)、数据库连接串没带characterEncodingutf8、数据库表本身是 latin1 字符集。这四个源头只要有一个没处理中文就会在某一环变形。最常见的组合是JSP 设了 UTF-8但 Servlet 接收 POST 请求时没设置请求编码导致参数在 Servlet 里已经被解码成乱码后面存进数据库自然也是乱的。解决四个地方全部统一成 UTF-8。JSP 头部写% page contentTypetext/html;charsetUTF-8 %Servlet 的doPost第一行写req.setCharacterEncoding(UTF-8)JDBC 连接串最后追?useUnicodetruecharacterEncodingutf8建表语句统一加DEFAULT CHARSETutf8。还有一处很容易漏Tomcat 8 之后 GET 请求的默认编码已经改成了 UTF-8但server.xml里的 Connector 节点如果被改动过URIEncodingUTF-8要确认还在。如果你改完以上四处还乱码看一下页面response.setContentType有没有被后置的resp.getWriter().write()覆盖把编码设置放在输出之前。5.2 请求 404 与 Servlet 映射的匹配规则现象点击“登录”按钮后浏览器地址栏跳对了但 Tomcat 返回 404或者 JSP 页面上引用的 CSS、图片全部加载不出来。原因第一个原因Servlet 的WebServlet(/login)和 JSP 里的表单actionlogin写成了相对路径。如果当前页面在/user/login.jsp表单提交到login会解析成/user/login跟真实的/login不匹配。第二个原因JSP 里引的 CSS 路径写死了hrefcss/style.css而当前浏览器地址是/dish/list浏览器会把它拼成/dish/css/style.css自然 404。解决表单和链接里的请求路径一定要带上下文根。JSP 页面里用${pageContext.request.contextPath}拼路径比如form action${pageContext.request.contextPath}/login methodpost静态资源的引用同样处理link href${pageContext.request.contextPath}/css/style.css。有人会问为什么不建议直接写死的项目名比如/takeout/login因为部署到 Tomcat 时如果改了解压目录名所有硬编码路径全会失效。用了 EL 表达式取上下文根war 包叫什么名字都无所谓了。还有一个细节IDEA 里配置 Tomcat 时有个Application context字段默认值是/项目名如果这里配成了/那访问路径又会不一样。启动后我习惯先访问首页右键查看页面源码确认静态资源路径拼得对不对再继续往下开发。5.3 传统 JSP 项目打包 war 并部署到 Tomcat现象毕设项目在 IDEA 里能跑但交上去的是一堆散落的.java和.jsp文件别人拿到手不会启动。原因IDE 里点运行按钮实际上用的是 IDEA 帮你在内存里布置好的 Tomcat开发环境正常不代表部署环境正常。传统 JSP 项目要交可运行的包正确做法是打出war 包放到 Tomcat 的webapps目录下启动 Tomcat 自动解压运行。解决Maven 项目的打包命令一行搞定mvn clean package执行完毕后war 包生成在target目录下文件名格式通常是takeout.war。把它复制到 Tomcat 安装目录的webapps下启动bin/startup.shWindows 下是startup.batTomcat 解压 war 包后自动生成同名目录。访问路径为http://localhost:8080/takeout/。有几点必须提前确认打包前检查数据库连接配置写的是不是外网能访到的地址本地开发时连接串里写localhost没问题但交到别人电脑上要改成对方的数据库地址。maven-war-plugin在pom.xml里不用额外配默认就会把src/main/webapp下所有文件打进 war 包。如果之前用了 IDEA 自带的 Artifacts 方式打过 war跟 Maven 产出的结构可能不一致建议只认 Maven 的产物。还有一个和 nginx 相关的点经常被误解nginx 不解析 JSP。nginx 是静态 Web 服务器它没有 JSP 解析引擎配置里把.jsp后缀的请求转发给 Tomcat 才有效。所以部署架构通常是 nginx 监听 80 端口location /转发到 Tomcat 的 8080 端口。如果毕设只需要演示不用折腾 nginxTomcat 单独跑就足够如果简历里写了 nginx那就要把这个转发配明白配置核心如下location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; }5.4 新用户看不到任何页面Session 存取的时机错了现象登录成功后页面跳转到了个人中心但页面上显示“未登录”或者压根取不到用户名。原因这不是随机 bug而是 Session 写入和跳转发生的时机错了。最常见的问题是登录 Servlet 里确实调用了session.setAttribute(user, user)但在这之前有一个resp.sendRedirect触发了一次新的请求而新的请求因为 Cookie 还没写入浏览器带不上 Session ID于是 Tomcat 给它分配了一个全新的 Session。表现为“登录成功后第一次跳转丢了登录状态刷新一下又好了”。解决重定向发生前把session.setAttribute和resp.sendRedirect的先后顺序梳理清楚。标准顺序是先向会话写数据再重定向。还有一个隐性细节req.getSession()第一次创建 Session 时容器会通过响应头Set-Cookie: JSESSIONIDxxx告诉浏览器存下这个 ID。但如果你在第一次写 Session 之后紧接着输出了一段 HTML 再重定向响应头可能已经被提交了Cookie 就发不出去了。所以登录成功的处理一律不要先getWriter().write()再重定向。调试这类问题有一个可靠办法浏览器开发者工具里看请求响应头的Set-Cookie如果重定向的响应里没有就说明 Session 创建太晚了。5.5 数据库外键与级联删除的表结构地雷现象删除一个分类时报外键约束错误Cannot delete or update a parent row或者商家想删除一个菜品管理员后台被各种关联表报错挡住。原因很多教程的建表 SQL 里喜欢写FOREIGN KEY加ON DELETE CASCADE看起来很方便删分类自动删菜品。但你违背了业务直觉——外卖系统的菜品删除应该是“逻辑删除”而不是物理删除。历史订单的明细引用着被删掉的菜品一旦级联删除历史订单的数据就残缺了。毕设答辩时老师经常会追问“删一个分类会发生什么”你如果回答“因为有外键所以删不掉”这反而是对的。解决我的建议是表之间不加物理外键只保留逻辑外键。比如订单表里建user_id字段但不声明FOREIGN KEY约束。菜品表加一个status字段0 表示下架1 表示上架商家删菜品就是UPDATE dish SET status 0而不是 DELETE。对外展示用WHERE status 1过滤管理员后台能看到下架的菜品。这样既保住了历史数据的完整性又不会在删数据时被数据库的约束规则绊住。有人担心不建外键数据会不一致这在学生项目里是可控的因为所有写操作都走自己的 DAO 层你在 Java 代码里保证逻辑一致就够了。6. 从跑通到答辩让项目在演示时经得住追问的几个收尾动作功能都跑通后离“能拿出手”还有一段距离。我每次带人做这类毕设项目都会要求补三样东西初始化数据脚本、演示脚本、日志输出。初始化数据脚本指的是init.sql里面除了建表语句还必须带足量的演示数据。别只插三条菜品评委打开菜品列表看到空荡荡的页面会很扫兴。我一般插 20 道菜品、3 个分类、4 个商家、2 个骑手、一个管理员账号、一个会员账号。账号密码直接在 README 里写明会员user/123456、商家shop/123456、骑手rider/123456、管理员admin/123456。这样答辩时评委要试哪个角色你 5 秒就能切过去。演示脚本是给自己看的。把整个流程排成固定顺序会员登录 → 浏览菜品 → 加购物车 → 下单 → 退出登录 → 商家登录接单 → 骑手登录配送 → 会员确认收货 → 管理员查看订单统计。每个步骤控制在 20 秒内。重点在于演示过程中不要临时想“下一个点什么”一犹豫就容易点错。日志输出方面给三个关键 Servlet 加上System.out.println或者用java.util.logging简单打点下单时打印订单号和总金额状态流转时打印 “订单 1001 从状态 0 变更为 1”。Tomcat 控制台会实时打印这些信息演示时评委能看到后台在按预想逻辑工作比我嘴上解释更有说服力。还有一个容易被追问的技术点Tomcat 里getSession和getSession(false)的区别、Servlet 是单例还是多例、Forward和Redirect的区别。这些问题都是 JSPServlet 项目里绕不开的。我自己的习惯是做完一个功能就在代码旁边用注释写一行“这里为什么这么做”攒到答辩前翻一遍能回忆起八成设计理由。最后给你一个我的个人习惯项目做完后我在web.xml里加了一个错误页配置把 404 和 500 都导向一个友好的提示页而不是让评委看到 Tomcat 默认的异常堆栈。这个细节花 10 分钟做完演示质感完全不一样。整个过程里踩过的那些坑乱码、路径失效、事务没回滚都是这个技术栈的一部分。希望这份拆解帮你在做这个外卖订餐系统时少走几步弯路做出来的项目既能跑得快也能讲得清。本文还有配套的精品资源点击获取