JavaEE订餐系统课程设计:Servlet+JSP+JDBC经典三层架构避坑实战
简介这份资源是《订餐系统基于JavaEE的课程设计》报告书以PDF形式呈现适合正在学习JavaEE框架、需要完成课程设计或毕业设计的学生参考。报告完整覆盖了网上订餐系统的选题背景、需求分析、总体设计、系统流程、数据库设计及功能实现并结合Struts、Spring、Hibernate等框架说明开发思路有助于读者掌握从需求建模到系统实现的全过程。压缩包内共1个PDF文件容量为985KB内容精炼但结构完整可直接用于学习梳理或作为设计文档撰写范本。目前已有748人浏览学习。报告中详细展示了菜品信息表、管理员信息表和用户注册信息表等数据库表结构配有系统功能框图和流程设计说明还包含异常处理配置等关键实现细节能帮助读者快速理解订餐系统的业务逻辑与代码组织方式。对于希望借鉴J2EE三层架构、MVC模式或数据库设计方法的同学而言这是一份实用的纸质化参考资料。1. 订餐系统JavaEE课程设计看似简单但能跑通且答辩不翻车的反而是经典路线JavaEE课程设计里订餐系统是出现频率最高的题目之一用户注册登录、菜品浏览、加购下单、订单管理、后台菜品维护业务链路完整每一环都能对应到课程里的知识点它又足够小单人花两三周就能写完。但一个反直觉的事实是越早上重量级框架的项目答辩时越容易露怯。框架帮你做的事越多你解释不清的部分就越多。带过几轮课程设计之后我发现老老实实用 Servlet JSP JDBC 写完的订餐系统从跑通到答辩的通过率反而最高。这篇文章就把从技术选型、数据库设计、核心代码到部署避坑的完整路径拆开来讲适合正在做或准备做这个题目的同学。2. 技术选型与分层架构为什么经典三层反而是课程设计的最优解2.1 为什么选 Servlet JSP 经典三层而不是一上来就 Spring Boot很多同学拿到题目第一反应是“直接上 Spring Boot反正简历上也写”。这个想法没有错但课程设计有一个隐藏评分标准你要能讲清楚系统是怎么跑起来的。Spring Boot 把 Tomcat、包扫描、依赖注入全部自动装配好启动完就给你一个能跑的项目这中间原理全是黑匣子。评委问“请求从页面到数据库走了一遍什么链路”你只能回答“框架帮我处理的”这就把分数交出去了。经典三层就不存在这个问题。我一般建议课程设计按这样的结构写Servlet 担任控制层接收请求、调服务、做转发JSP 担任视图层负责渲染页面DAO 层用 JDBC 直接操作数据库。Service 层要不要单独抽出来取决于题目复杂度——订餐系统有下单事务我建议你抽一层 Service把事务控制放在里面答辩时这就是一个可以主动讲的亮点。为了减少繁琐配置Servlet 直接用WebServlet注解而不是web.xml。普通 Servlet 3.0 以上就支持注解配置Tomcat 7 之后默认开启省掉一堆 XML 配置的讲解负担评委看起来也更清爽。维度经典三层ServletJSPJDBCSpring Boot 全家桶学习成本低每个环节都可见中高先得理解自动装配答辩可解释性高能讲清完整请求链路低容易变成“框架帮我做的”依赖数量少一个 Tomcat 就够多起步就是一堆 starter事务与连接管理自己写代码能讲原理声明式事务几句话带过适合场景课程设计、毕业设计简版工程级项目、实际业务开发所以结论不是 Spring Boot 不好而是对课程设计这个目标来说经典三层让你花最少的时间拿到最高的答辩分数。等你把这条路走通了再自己去接触 Spring Boot理解会深刻得多。2.2 数据库设计六张表撑起整个订餐系统的数据边界订餐系统的数据库设计不用复杂五到六张表足矣。核心是围绕“用户-菜品-订单”这三类实体展开。我常用的设计是这样的表名作用关键字段user用户与角色id, username, password, phone, address, rolecategory菜品分类id, namedish菜品主数据id, category_id, name, price, image, statuscart购物车可选用 Session 替代user_id, dish_id, quantityorders订单主表id, order_no, user_id, total_price, status, create_timeorder_item订单明细表id, order_id, dish_id, price, quantityuser 表里我用role字段区分普通用户和管理员0 表示普通用户1 表示管理员。这样后台管理的登录校验就复用同一个登录接口只是登录后根据 role 决定跳转到用户端还是管理端少写一套用户表。dish 表里必须有status字段用 0/1 表示菜品是否上架。删除菜品时不要物理删除直接置为 0这样历史订单里还能引用菜品名称不会因为删菜导致订单明细变成孤数据。orders 和 order_item 为什么要拆成两张表因为一个订单可能包含多个菜品一张表装不下“一对多”的关系。拆开后订单主表存总金额和状态明细表存每个菜品的快照价格和数量。快照价格尤其重要因为菜品价格会调下单时价格必须定格在那一刻不能跟着菜表变动。外键我没强制在数据库层声明而是用 Java 层面对应关系来维护。物理外键在项目初期看着清晰但删除数据、批量导入时会带来顺序依赖课程设计里反而容易踩坑。逻辑外键只要 Java 代码控制得当完全够用。给核心表一个最小建表 SQL可以直接抄CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT 存MD5摘要不存明文, phone VARCHAR(20), address VARCHAR(255), role TINYINT DEFAULT 0 COMMENT 0-普通用户 1-管理员 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dish ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, image VARCHAR(255), status TINYINT DEFAULT 1 COMMENT 1-上架 0-下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 COMMENT 0-待处理 1-已完成 2-已取消, create_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, dish_id INT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 下单时的价格快照, quantity INT NOT NULL DEFAULT 1 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集统一用utf8mb4排序规则用默认的即可。utf8mb4能存表情符号和生僻字最关键的是它覆盖了所有中文字符避免出现“字库不够”这种低级问题。金额字段必须用DECIMAL(10,2)不要用 FLOAT 或 DOUBLE浮点数的精度在累加时会出问题答辩时这属于基础知识考察点。2.3 购物车到底放 Session 还是数据库一种取舍先说结论购物车是订餐系统里最容易犹豫的地方。放数据库表结构清晰、数据不丢但每加一个菜就要写一次库还要设计购物车表的增删改查放 Session实现简单刷新页面不丢但服务器一重启就没。课程设计阶段我的结论是放 Session理由有三条。第一课程设计重在演示核心链路购物车不是业务终点下单才是。购物车用 Session 实现把内存操作集中在一个 Map 里代码量远少于数据库方案。第二Session 是 JavaWeb 课程的核心知识点把购物车放 Session 并讲清楚原理本身就是答辩得分点。第三数据库购物车需要在登录态和游客态之间做数据合并这个逻辑比较复杂课程设计投入产出比不高。Session 购物车用一个MapInteger, Integer就够了key 是菜品 IDvalue 是数量。加购代码大概是这个样子// 从Session取出购物车没有则创建 HttpSession session request.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new LinkedHashMapInteger, Integer(); session.setAttribute(cart, cart); } // 把菜品ID和数量放入购物车 int dishId Integer.parseInt(request.getParameter(dishId)); int quantity 1; if (cart.containsKey(dishId)) { quantity cart.get(dishId) 1; // 已存在则数量1 } cart.put(dishId, quantity); response.sendRedirect(request.getContextPath() /cart.jsp);这段代码有一个训练价值点containsKey和get的配合。很多同学第一版会写成先get再判空但Map.get返回 null 有两种情况——key 不存在和 value 本身就是 null。虽然我们用 Integer 包装类型不会存 null但在答辩时能主动说出这个细节会显得代码习惯很好。下单提交成功之后务必调用session.removeAttribute(cart)清空购物车。这一步漏掉是常见错误会导致“下单完毕购物车还在”的尴尬局面后面避坑章节会单独讲。3. 核心功能打通登录、菜品列表、下单事务的落地代码3.1 登录与权限拦截从表单提交到 Filter 控制登录功能看起来简单但它是整个系统里链路最完整的请求JSP 表单提交 - Servlet 接收参数 - 调用 DAO 查库 - 比对密码 - 写 Session - 跳转。每一步都对应一个 JavaWeb 基础点值得认真写。密码不要明文存库。课程设计不需要上 BCrypt 这种加密库用 JDK 自带的MessageDigest做 MD5 摘要就够重要的是有这个意识。写一个工具方法WebServlet(/login) public class LoginServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); // MD5摘要后比对数据库避免明文密码落库 String md5 md5Digest(password); User user userDao.findByUsernameAndPassword(username, md5); if (user ! null) { // 登录成功写入Session并跳转到菜品列表 HttpSession session request.getSession(); session.setAttribute(loginUser, user); response.sendRedirect(request.getContextPath() /dish/list); } else { // 登录失败回传错误信息并留在登录页 request.setAttribute(error, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } private String md5Digest(String source) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] bytes md.digest(source.getBytes(UTF-8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(MD5加密失败, e); } } }注意sendRedirect和forward的差别重定向是浏览器重新发起一次请求地址栏会变适合登录成功后跳转新页面转发是服务器内部转发地址栏不变适合登录失败后留在当前页并携带错误信息。把这个讲清楚答辩时能少被追问好几个问题。登录态有了下一步是拦截未登录访问。不要在每个 Servlet 里重复判断用一个 Filter 统一处理WebFilter(/*) public class AuthFilter implements Filter { 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(); // 登录页、登录接口、注册页和静态资源直接放行 if (uri.endsWith(/login.jsp) || uri.endsWith(/login) || uri.endsWith(/register.jsp) || uri.contains(/static/)) { chain.doFilter(req, resp); return; } // 其余请求必须已登录否则跳回登录页 HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }getSession(false)这里有个容易被忽略的点参数为 false 表示“没有 Session 就返回 null而不是新建”。如果用无参的getSession()未登录用户每次访问都会被迫创建 Session给攻击者伪造会话留下空间也浪费内存。Filter 统一拦截的价值在于你不小心漏写了某个页面的权限判断Filter 兜底帮你拦住。3.2 菜品分页列表PreparedStatement 和 LIMIT 的配合菜品列表是系统里最普通的查询但它有两个可讲的点防 SQL 注入和分页。防 SQL 注入不用多说用PreparedStatement占位符是铁律分页则是 DAO 层的常用套路SQL 上做一个LIMIT ? OFFSET ?就能实现。public ListDish findPage(int page, int pageSize) { // page从1开始offset换算公式 int offset (page - 1) * pageSize; String sql SELECT * FROM dish WHERE status 1 ORDER BY id LIMIT ? OFFSET ?; ListDish list new ArrayListDish(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, pageSize); ps.setInt(2, offset); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Dish dish new Dish(); dish.setId(rs.getInt(id)); dish.setName(rs.getString(name)); dish.setPrice(rs.getBigDecimal(price)); dish.setImage(rs.getString(image)); list.add(dish); } } return list; } catch (SQLException e) { throw new RuntimeException(分页查询菜品失败, e); } }LIMIT后面第一个参数是查询条数第二个参数是偏移量这个顺序别记反。很多同学一上来写LIMIT ? , ?传的是 (page, pageSize)结果第一页正常第二页就多出一条重复数据。正确写法是LIMIT pageSize OFFSET (page-1)*pageSize。在 Servlet 里接收页码参数时一定要做默认值和边界处理。用户访问/dish/list?page0或page-1是常态不是异常String pageStr request.getParameter(page); int page 1; if (pageStr ! null) { try { page Integer.parseInt(pageStr); } catch (NumberFormatException e) { // 参数不合法时回落到第一页而不是报500 page 1; } } if (page 1) { page 1; }参数解析的健壮性在课程设计评审时是个容易被忽略的加分点。评委不会只点正常按钮他们会乱点。你提前在边界做了兜底就能少一次现场翻车的几率。3.3 下单事务把扣库存和写订单绑在同一个 Connection 上下单是订餐系统里唯一需要事务的地方。一个完整的下单动作包含三件事往 orders 表插一条记录、往 order_item 表插 N 条明细、更新菜品销量。这三件事只要有一个失败订单就是残缺的。最常见的翻车写法三个 DAO 方法各拿各的连接、各自动提交结果订单主表写进去了明细因为 SQL 报错没写入系统里出现一笔“幽灵订单”。正确做法是把事务提升到 Service 层用一个连接执行全部操作public void createOrder(Order order, ListOrderItem items) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 核心关闭自动提交开启手动事务 // 第一步插入订单主表拿到自增主键 String insertOrder INSERT INTO orders(order_no, user_id, total_price, status, create_time) VALUES(?, ?, ?, ?, NOW()); int orderId -1; try (PreparedStatement ps conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setInt(2, order.getUserId()); ps.setBigDecimal(3, order.getTotalPrice()); ps.setInt(4, 0); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { orderId keys.getInt(1); } } } // 第二步插入订单明细批量执行 String insertItem INSERT INTO order_item(order_id, dish_id, price, quantity) VALUES(?, ?, ?, ?); try (PreparedStatement ps conn.prepareStatement(insertItem)) { for (OrderItem item : items) { ps.setInt(1, orderId); ps.setInt(2, item.getDishId()); ps.setBigDecimal(3, item.getPrice()); ps.setInt(4, item.getQuantity()); ps.addBatch(); } ps.executeBatch(); } conn.commit(); // 全部成功提交事务 } catch (Exception e) { // 任何一步失败回滚全部操作不留残缺订单 if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException(下单失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); // 恢复自动提交方便连接复用 conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }三个细节值得记住。第一prepareStatement第二参数传Statement.RETURN_GENERATED_KEYS这样插入主表后能回掏出自增 ID再拿这个 ID 去插明细表两个表才能通过order_id关联起来。第二明细表插入用addBatchexecuteBatch一次性提交多条记录比 for 循环单条插入性能高。第三finally里一定要恢复自动提交再关连接否则连接池复用时下一个使用者拿到的还是一个事务未提交状态的连接这时候怎么排查都查不出来。事务这块代码我建议你自己手写不要用 IDE 的模板生成。手写一遍才能真正记住setAutoCommit、commit、rollback这三个方法的顺序和意义答辩时被问到“事务是怎么实现的”能直接从这个项目里拿答案。4. 从零搭环境到跑通项目版本搭配、部署流程与演示数据4.1 版本搭配JDK 8、Tomcat 9、MySQL 5.7 是最少踩坑的组合环境问题占课程设计报错的六成以上而且多数是版本不匹配引起。我的习惯是固定用一套“零意外”的组合JDK 8 Tomcat 9 MySQL 5.7 Servlet 4.0。这套组合的兼容性已经被大量项目验证过少踩很多组合坑。组件推荐版本原因JDK1.88u 之后任意小版本兼容性最好Tomcat 9 和多数 IDE 默认支持Tomcat9.x支持 Servlet 4.0内置 WebSocket配置简单MySQL5.7JDBC 驱动和 URL 参数最稳定不乱报时区错误数据库驱动mysql-connector-java 5.1.49与 MySQL 5.7 配合成熟开发工具任意 IDE只要能导出 WAR 包和跑 Maven/Tomcat 插件即可JDK 11 以上的坑主要在模块化部分老版本 Tomcat 不兼容、某些依赖需要额外加--add-opens参数。你不是在做新项目选型而是课程设计追求的是评审那天系统能稳定跑起来。JDK 8 是老一点但对这个项目来说完全够用而且不容易出幺蛾子。MySQL 8 的坑值得单独说它默认的认证插件是caching_sha2_password老版本的 Java 驱动识别不了连接时直接报Public Key Retrieval is not allowed。新驱动版本换了mysql-connector-java 8.x后URL 里还要加允许公钥检索参数。为了省这些折腾直接用 5.7 组合最稳。4.2 部署流程打包 WAR、丢进 webapps、用 curl 验证部署方式有两种一种是 IDE 里内置 Tomcat 直接跑适合开发调试另一种是导出 WAR 包扔到独立 Tomcat 的 webapps 目录下适合交付和评审演示。课程设计答辩建议用第二种因为评委的环境大概率不是你开发用的那台机器你需要在“任意装了 Tomcat 的机器”上都能跑起来。WAR 包部署的完整流程在 Linux 或 Mac 上操作是这样# 1. 在项目根目录用 Maven 打包跳过测试用例 mvn clean package -DskipTests # 2. 把打好的 WAR 包复制到 Tomcat 的 webapps 目录下 # WAR 包的文件名 访问路径的上下文名 cp target/order-system.war $CATALINA_HOME/webapps/ # 3. 启动 Tomcat在 Tomcat 的 bin 目录下执行 $CATALINA_HOME/bin/startup.sh # 4. 验证用 curl 看 HTTP 状态码-s 静默-o /dev/null 丢响应体-w 输出状态码 curl -s -o /dev/null -w %{http_code}\n http://localhost:8080/order-system/dish/list # 5. 看启动日志是否符合预期 tail -f $CATALINA_HOME/logs/catalina.out打完包后你需要确认 WAR 包内WEB-INF/classes下有编译好的 class 文件WEB-INF/lib下有数据库驱动 jar 包。如果驱动 jar 没打进去部署后一访问数据库相关页面就抛ClassNotFoundException: com.mysql.jdbc.Driver。这个检查 10 秒钟能省掉一次现场事故。curl 返回 200 说明功能路由正常返回 302 属于正常情况因为菜品列表页被 Filter 拦截跳去了登录页你可以加一个参数跟随跳转来验证curl -sL -o /dev/null -w %{http_code} http://localhost:8080/order-system/login.jsp返回 200 就说明登录页正常。WAR 包的名字就是访问路径的一部分这个细节很重要。你要访问http://localhost:8080/order-system/路径下才能进入系统不是根路径http://localhost:8080/。要改成根路径访问就得把 WAR 包改名为ROOT.war覆盖掉原 Tomcat 自带的 ROOT 应用。4.3 演示数据初始化给系统一批让答辩顺畅的样例数据系统写好了但空数据库没法演示。我给每个项目都准备一份初始化 SQL包含管理员账号、菜品分类、上架菜品、一个测试用户以及一两笔演示订单。有了测试订单你在答辩现场演示“查看订单”功能时不会因为库里空无一物而尴尬。-- 管理员账号admin / 123456密码为MD5摘要 INSERT INTO user(username, password, phone, address, role) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 13800000000, 系统管理部, 1); -- 测试用户test / 123456 INSERT INTO user(username, password, phone, address, role) VALUES (test, e10adc3949ba59abbe56e057f20f883e, 13900000000, 测试小区1栋, 0); -- 菜品分类 INSERT INTO category(name) VALUES (招牌菜), (素菜), (汤品), (主食); -- 菜品价格用DECIMAL图片路径写成相对路径方便本地访问 INSERT INTO dish(category_id, name, price, image, status) VALUES (1, 招牌红烧肉, 46.00, images/dish1.jpg, 1), (2, 清炒时蔬, 18.00, images/dish2.jpg, 1), (3, 番茄蛋花汤, 16.00, images/dish3.jpg, 1), (4, 米饭, 2.00, images/rice.jpg, 1); -- 一条测试订单确保订单列表页有数据显示 INSERT INTO orders(order_no, user_id, total_price, status, create_time) VALUES (202501010001, 2, 48.00, 1, NOW()); INSERT INTO order_item(order_id, dish_id, price, quantity) VALUES (1, 1, 46.00, 1), (1, 4, 2.00, 1);注意测试账号密码统一用e10adc3949ba59abbe56e057f20f883e这是字符串123456的 MD5 摘要。我把密码有规律的测试账号写死在脚本里方便记忆也方便演示时快速输入。菜品图片不要用网络 URL本地放一个 images 目录因为评审现场很可能没有外网图片一旦无法加载整个页面美观度断崖式下降。演示数据是一份两用的资产既能自己在开发时快速联调又能在答辩前快速重置数据库。建议在项目根目录放一个docs/init.sql随时可以重新执行恢复演示状态。5. 课设避坑指南五条血泪经验按现象、原因、解决逐条排查5.1 现象页面中文全是问号或者请求参数里的中文变成乱码这个坑几乎每个用 JSP 的项目都会踩一次。表现有三种页面显示中文正常但提交到后台变乱码、数据库读出来变问号、JSP 页面本身显示乱码。原因是一处编码不一致导致全线崩溃——请求编码、响应编码、数据库连接编码、表字符集四处只要有一处和别处不一致乱码就会在某个环节冒出来。解决四处全部统一为 UTF-8。第一Java 代码里 Servlet 开头加request.setCharacterEncoding(UTF-8)JSP 第一行加% page contentTypetext/html; charsetUTF-8 %。第二JDBC URL 末尾加characterEncodingutf8。第三建表时用DEFAULT CHARSETutf8mb4覆盖前面说的所有表。这段排查的优先级是先看 JSP 页面声明和表单提交、再看 Servlet 的 request 编码、再查 JDBC URL 和表字符集按这个顺序找十分钟就能定位。5.2 现象本地开发跑得好好的换一台机器部署就报数据库连接失败这个坑的典型表现是Communications link failure或者Access denied for user。首先排除代码问题——大概率是连接信息写死在代码里而换环境后数据库 IP、端口、账号密码都对不上了。很多同学的 JDBC URL 写的是localhost在本地没问题到了评委的机器上评委的数据库用户名密码和你的不一致立刻报错。解决把数据库连接信息抽到一个配置文件里我一般放在src/main/resources/db.properties用Properties类读取。部署之前检查三样东西jdbc.url里的 IP 和端口是否可达、jdbc.username和jdbc.password是否匹配目标库、MySQL 驱动 jar 是否在 WAR 包的WEB-INF/lib下。另外如果你在代码里用了serverTimezone参数目标机器的 MySQL 版本能识别吗MySQL 5.7 识别Asia/Shanghai老版本不一定认用 5.7 组合就没这个问题。5.3 现象页面只有纯文字CSS 和图片样式全丢了看起来像“功能正常但丑得没法看”。原因是 JSP 里写了绝对路径href/css/style.css或src/images/logo.png。本地开发时通过 IDE 内置 Tomcat 跑上下文路径通常是/绝对路径能用部署后上下文变成/order-system浏览器去请求/css/style.css就会 404。解决所有静态资源路径改用${pageContext.request.contextPath}拼接。例如link relstylesheet href${pageContext.request.contextPath}/css/style.css。这个写法在 JSP 页面里通用部署到任意上下文路径下都能正确解析。也可以用c:url标签库原理相同。排查时直接在浏览器按 F12 看 Network 面板凡是返回 404 的静态资源请求都是路径拼接问题。5.4 现象Tomcat 启动时端口被占用页面一直打不开这个坑在答辩现场特别容易踩。之前调试留下的 Tomcat 进程没关干净或电脑上有其他程序占用了 8080 端口启动报Port 8080 was already in use。很多同学现场不知道怎么看端口手忙脚乱地重启电脑白白浪费时间。解决Linux 或 Mac 用netstat -tlnp | grep 8080Windows 用netstat -ano | findstr 8080拿到进程 PID 后直接kill掉占用进程再重新启动 Tomcat。另一个方案是当场改 Tomcat 端口编辑conf/server.xml里的Connector port8080改成 8081 或 8082注意同时确认新端口也没被占用。用tail -f logs/catalina.out看启动日志没有异常输出再访问页面。5.5 现象下单成功订单表里有数据但购物车没清空这个属于逻辑缺陷不是环境问题。表现是用户提交订单后返回购物车页看到刚才买的东西还在再点一次下单又生成一笔重复订单。原因是下单成功后的跳转路径处理不当用户手动刷新了页面或者下单价接口在代码里没有及时清 Session。解决下单成功的 Controller 里session.removeAttribute(cart)一定要在跳转前执行。同时提交订单的接口用 Redirect 而不是 Forward——刷新页面时Forward 会重新执行一次下单逻辑产生重复订单Redirect 会刷新成订单列表页有效规避重复提交。再加一个简单的防抖逻辑前端在点击下单按钮后置灰后端收到订单号再检查数据库唯一索引防止同一笔订单被提交两次。这些点写进报告书“防重复提交设计”是一个像样的亮点。6. 报告书加分项把设计过程写成答辩能打的文档6.1 报告书的章节顺序比代码篇幅更重要报告书不需要照抄课程设计模板的章节目录我一般建议按“需求分析 - 系统设计 - 数据库设计 - 核心实现 - 测试记录”这五块来组织。核心实现不要贴大段代码挑三段登录鉴权链路、下单事务、分页查询。每段配文字说明“为什么这么写”比贴一百行代码有用。6.2 答辩演示脚本提前准备好一个顺手的操作序列答辩演示按照一条主线来注册新用户 - 登录 - 浏览菜品并加购 - 提交订单 - 查看订单列表 - 切换管理员查看后台订单处理。每步动作 10 秒内能看到结果。最值得专门准备的是事务演示打开两个页面同时提交两个订单展示库存扣减和订单生成的一致性。最后准备一句总结语这个系统用经典 JavaEE 三层架构从会话管理、事务控制到分页查询完整覆盖了课程核心知识点的工程化应用。我带过的历届课设中因为这个结语而多拿分的人不在少数。实在紧张时记住一个习惯把演示环境调通后再合上电脑带上备用的 WAR 包万一现场机器故障还有退路。希望帮到你。本文还有配套的精品资源点击获取