
早餐外卖这个场景特别适合JSPServlet这套老牌技术栈来练手业务链路完整从前台点餐到后台出餐都有又不至于复杂到失控。今天把这个基于JavaWeb和MySQL的JSPServlet早餐外卖店管理系统拆开揉碎讲一遍从数据库设计到前端交互从本机部署到常见报错所有流程都是可以照着重现的不是那种飘在PPT上的概念方案。想把这套系统吃透的人我大概分成两类一类是刚学完JavaWeb正在找完整项目做课程设计或毕业设计的同学另一类是工作里被Spring Boot“惯坏了”想回头补一补原生Servlet、JSP、会话管理这些底层原理的开发者。这个项目能解决的核心问题就是让你完全不用框架纯靠Servlet控制请求流转、JSP渲染页面、JDBC操作MySQL自己手工拼出一套能用的外卖管理系统。这么做最大的价值不是“代码能跑”而是你能真正看清楚一个Web应用在浏览器和数据库之间到底干了些什么。项目本身的业务不复杂顾客浏览早餐分类和菜品加入购物车提交订单填写收货地址和期望送达时间管理员在后台维护菜品、处理订单状态、查看每日营业额。技术点覆盖了JavaWeb几乎全部核心内容Filter过滤器做登录拦截、Session维护登录态和购物车、JDBC做增删改查、事务保证下单扣库存的一致性、AJAX局部刷新、JSP标签库遍历列表。下面按我的实际开发顺序把每个环节的关键细节和坑都说清楚。1. 整体架构与设计取舍 —— 为什么坚持用 JSPServlet 做外卖系统1.1 技术选型背后的真实考量有人会问都2025年了谁还用JSPServlet写新项目这个质疑本身没错但得分场景。如果是公司里的正式产品我绝对推荐Spring Boot MyBatis / JPA那一套但如果是用来学习JavaWeb、理解HTTP请求如何被处理、Session如何维持状态、SQL如何被程序调用那JSPServlet是最直接的地图。把所有自动化都去掉之后底层的轮廓才会浮出水面。这个早餐外卖系统的业务规模也不大属于典型的“高并发需求低、业务逻辑直观、页面数量适中”的项目。早餐店高峰时段就是早上七点到九点单量密集但总量有限用Servlet线程池处理完全够用。反而如果用Spring Boot起步你会发现大部分精力耗在理解自动配置上而不是业务本身。所以我刻意选择了“裸奔”方案MVC三层全部手写连接池用简单的DBCP或者干脆自己封装JDBC工具类分页逻辑自己计算事务边界自己在Service层控制。写完之后再回头看Spring Boot很多概念都是自然而然的对应关系。1.2 系统模块划分与角色权限我把系统拆成前台门户和后台管理两大块对应两种角色普通用户和管理员。前台给顾客看菜单、下单、查自己的订单后台给店员管理菜品类别、上下架早餐、处理订单状态。权限控制的核心就一句话Session里有没有登录用户以及这个用户的role字段是不是1。目录结构我建议这样组织注意包名要语义清晰com.food ├── controller // Servlet类处理请求转发和重定向 ├── service // 业务逻辑层事务控制在这里 ├── dao // JDBC数据访问层 ├── entity // 实体类对应数据库表 ├── filter // 编码过滤器、登录拦截过滤器 ├── util // DBUtil、MD5工具类等 └── listener // 可选启动时加载分类缓存JSP页面放在webapp目录下前台页面和后台页面用文件夹区分webapp/front、webapp/admin。这样的好处是路径清晰写Servlet重定向时不会把前台后台弄混。1.3 一个Servlet还是多个Servlet这个点是新手最容易纠结的地方。我见过不少人图省事写一个DishServlet里面用if (list.equals(action))、else if (add.equals(action))分别处理不同请求。项目小的时候这么写没问题但早餐外卖系统做到后面至少有十几个功能动作——菜品增删改查、分类管理、订单处理、登录注册、购物车操作——全塞一个Servlet会让代码膨胀到几百行排查问题非常痛苦。我采用的方案是“一个业务实体一个Servlet”。菜品相关操作放DishServlet订单相关放OrderServlet用户相关放UserServlet购物车相关放CartServlet。每个Servlet里用action参数通过request.getParameter(action)获取区分具体方法。这个模式其实已经非常接近Spring MVC的RequestMapping思路了理解这种对应关系之后以后学框架会轻松很多。2. 数据库设计与核心建模 —— 一份能应付答辩的 MySQL 表结构2.1 分类表与菜品表的设计早餐外卖店的菜单很有特点品类固定、单品价格不高、常有“豆浆油条”这种套餐逻辑。所以我的设计是“分类表菜品表”的主从结构分类表在前台渲染成导航栏分类菜品表按分类展示。分类表结构简单重点是状态字段和排序字段CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 分类ID, name VARCHAR(50) NOT NULL COMMENT 分类名称粥品/煎饼/豆浆/套餐, sort_order INT DEFAULT 0 COMMENT 排序权重值小的靠前, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );菜品表要注意的字段多一些我把关键字段贴出来CREATE TABLE dish ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL COMMENT 所属分类ID, name VARCHAR(100) NOT NULL COMMENT 菜品名称, description VARCHAR(500) COMMENT 菜品描述前台列表展示, price DECIMAL(10,2) NOT NULL COMMENT 现价, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 原价用于划线价展示, image VARCHAR(200) COMMENT 图片路径存相对路径, stock INT DEFAULT 0 COMMENT 当日库存卖完自动下架, sales_count INT DEFAULT 0 COMMENT 累计销量用于“热销”排序, status TINYINT DEFAULT 1 COMMENT 1在售 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );两个细节必须强调第一金额字段统一用DECIMAL(10,2)绝对不要用FLOAT或DOUBLE外卖订单涉及大量金额累加和单价比较浮点误差会带来对不上账的问题第二图片存的是webapp/upload下的相对路径比如/upload/youitao.jpg页面渲染时用request.getContextPath()拼接成完整路径。千万别把完整URL写死进数据库换域名、换端口的时候会疯掉的。2.2 订单主表与订单明细表建模早餐外卖的订单和正餐外卖有区别用户可能是前一天晚上预订第二天早上的餐所以“期望送达时间”是必须的字段而且前台下单时要限制只能选明天及以后的时间。订单设计成主从两张表主表存一次订单的整体信息明细表存这单里每种餐品的数量、单价、小计。CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单号年月日随机数, user_id INT NOT NULL COMMENT 下单用户ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付备餐中 2配送中 3已完成 4已取消, receiver_name VARCHAR(50) NOT NULL COMMENT 收货人姓名, receiver_phone VARCHAR(20) NOT NULL COMMENT 联系电话, delivery_address VARCHAR(200) NOT NULL COMMENT 配送地址, expected_time DATETIME NOT NULL COMMENT 期望送达时间, remark VARCHAR(200) COMMENT 订单备注比如“不要香菜”, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_id(user_id), INDEX idx_status(status), INDEX idx_expected_time(expected_time) ); CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL COMMENT 订单ID, dish_id INT NOT NULL COMMENT 菜品ID, dish_name VARCHAR(100) NOT NULL COMMENT 下单时的菜品名称快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时的单价快照, quantity INT NOT NULL COMMENT 数量, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计金额 );这里有一个菜鸟经常忽略的设计为什么订单明细表里要冗余dish_name和price这两个字段因为早餐店的菜品是会改价、甚至会删除的。如果订单只存dish_id一个月后店长把豆浆从两块钱涨到三块钱你再回去看一个月前的订单报表金额全变了。这叫“历史数据快照”外卖系统的订单明细必须保留下单那一刻的商品信息这是行业通用做法。2.3 外键、索引和命名规范的实操建议外键约束在学术上很漂亮但在实际项目里我建议尽量不要加物理外键尤其是订单明细表关联菜品表、订单表关联用户表这种高频查询链路。理由有两条一是早餐外卖高峰期密集下单物理外键在插入和删除时会有额外的完整性检查开销二是做订单删除或菜品归档时物理外键会挡住操作还得先处理关联数据。业务上的一致性通过Service层事务来控制数据库只加普通索引。索引方面订单表里user_id、status、create_time这三个字段是查询高频字段前台用户查“我的订单”、后台管理员按状态筛选订单、按时间范围统计营业额都靠这三个索引。但注意不要无脑加索引——订单状态只有几个固定值区分度很低单独加索引收益不大我更推荐建联合索引(status, create_time)这样既能按状态筛也能按时间排一个索引覆盖两种场景。命名规范方面我习惯表名用单数字段名全部小写加下划线user_id不写成userIdcreate_time不写成createTime。Java实体类的驼峰命名和数据库下划线命名之间在DAO层写映射时手动转换即可项目不大没必要引入ORM框架但字段命名统一能让代码审查不闹心。主键一律叫id逻辑外键直接叫xxx_id一看就能明白关联关系。3. 核心功能模块实现细节 —— 从登录到结算的完整闭环3.1 用户登录与会话管理登录是几乎所有Web系统的入口早餐外卖系统也不例外。我在UserServlet里写了个login方法流程就是拿到用户名和密码先做非空校验然后调Service层的login()方法返回用户对象或null。密码存储那边用了MD5加盐的方式注册时把盐和密码哈希一起存库登录时用同规则哈希后比对——虽然是老技术但配合盐值仍然能挡住大部分“拖库撞库”的低级攻击。加盐的核心是一人一盐让彩虹表失效。登录成功后做法是request.getSession().setAttribute(loginUser, user)。退出登录时不仅要session.invalidate()还要记得把Cookie里的JSESSIONID清理掉不然浏览器还会在响应头里带一个无效的会话ID。Session这块要提醒一个容易踩的坑不要把整个User对象直接塞在Session里当JSON用然后在JSP里取${loginUser.password}这种危险操作。正确做法是把密码字段设为null再放入Session或者单独建一个SafeUser对象只保留id、用户名、手机号、角色这些安全字段。登录拦截用Filter来实现最合适。我建了一个LoginFilter在web.xml里配置映射路径WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 放行登录页、注册页、静态资源、前台浏览接口 if (uri.endsWith(login.jsp) || uri.endsWith(register.jsp) || uri.contains(/static/) || uri.contains(/upload/) || uri.contains(userServlet?actionlogin) || uri.contains(userServlet?actionregister) || uri.contains(dishServlet?actionlist)) { chain.doFilter(request, response); return; } // 其余页面必须登录 HttpSession session request.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() /front/login.jsp); } } }注意request.getSession(false)里的false——意思是如果没有Session就返回null而不是新创建一个。这个细节很关键未登录用户访问后台页面时不应该被“强行创建Session”白白浪费服务器内存还让会话ID有了可乘之机。3.2 菜单展示、分页搜索与图片坐标定位前台的菜单页是整个系统门面也是JSP标签库用得最密集的地方。我用JSTL的c:forEach遍历菜品列表加上c:choose实现“有图显示图、无图显示占位图”的逻辑。分类导航栏用c:if判断当前选中分类高亮。分页必须自己写清楚页面传pageNum参数每页固定6个菜品因为早餐店数量不大一页放太少显得空旷放太多又太长DAO层先用SELECT COUNT(*)算总量再执行带LIMIT的查询int pageSize 6; int totalCount dishDao.countByCategory(categoryId); int totalPages (int) Math.ceil(totalCount * 1.0 / pageSize); int offset (pageNum - 1) * pageSize; ListDish dishList dishDao.findByCategory(categoryId, offset, pageSize);页码导航怎么做我的方案是直接用c:forEach从1循环到totalPages生成?pageNum${i}categoryId${categoryId}的链接。这里有个细节翻页时一定要把categoryId也带上并且当前页码要控制在1和totalPages之间防止用户直接在地址栏输入pageNum999导致SQL越界。图片坐标定位这个需求在早餐外卖里有实际应用场景——比如菜单页的一张菜品展示图上标注“豆浆 2元 / 油条 3元 / 茶叶蛋 1.5元”点击不同坐标区域跳到对应菜品分类。原生JSP做这个很简单不算复杂给图片设置usemap属性用HTML的map和area标签划定多个圆形或矩形热区每个热区的href指向不同查询链接img src${ctx}/upload/breakfast_map.png usemap#breakfastMap alt早餐地图 map namebreakfastMap area shaperect coords10,10,120,120 href${ctx}/dishServlet?actionlistcategoryId1 alt粥品 area shapecircle coords180,80,40 href${ctx}/dishServlet?actionlistcategoryId2 alt煎饼 /mapcoordinates的四个数字分别代表矩形左上角和右下角的x、y坐标值确定区域时直接对着图片坐标量就行。如果要求更细致的交互可以嵌入一小段JavaScript监听图片的click事件用event.offsetX和event.offsetY判断点击位置落入哪个区域范围再手动跳转。两种方式对比HTML map标签简单可靠但不灵活JS判断可以加高亮提示、弹窗预览体验更好代价是要自己写逻辑。3.3 购物车实现——为什么用Session而不用数据库表购物车这环节错误答案最多。有些人习惯性地建一张cart_item表用户加一次购物车就写一次数据库提交订单时再逐条读出来、删除。这个方案在体验上有个硬伤用户逛了半天加了一堆菜中途浏览器直接关掉或者Session超时这些购物车数据就成了垃圾数据你还得定时清理。而且早餐外卖的场景里购物车生命周期极短从加餐到下单往往不超过几分钟根本犯不着落到数据库。我在这个项目里把购物车直接放在Session里结构是MapInteger, CartItem——key是菜品idvalue是包含菜品信息、数量、小计的购物车项。实现思路是MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMapInteger, CartItem(); session.setAttribute(cart, cart); } // 加购时判断key存在就数量1不存在就新建一项这个方案的好处和柜台点餐的体验一致加购不需要访问数据库零延迟购物车数据随Session自然销毁没有脏数据积压。缺点你也能想到——用户在手机上逛着逛着把浏览器关了购物车就没了。但在早餐外卖这种低决策成本、即点即走的场景下这是完全可以接受的取舍。购物车页面的“数量加减”按钮要实现一个细节数量减少到0时这一项自动从Map里移除。营业额统计和库存扣减都依赖购物车数据的准确性不要在页面上显示数量为0的餐品后还能提交会让订单金额算错。3.4 订单提交流程与事务控制下单是整个系统的核心交易环节最容易出数据不一致的地方。我按下面的步骤设计Service层的submitOrder方法从Session里拿购物车校验非空从Session里拿登录用户校验非空遍历购物车明细逐项检查菜品仍在售、库存够数计算订单总金额循环累加每个明细的subtotal生成订单号形如20250127093000001纯数字避免和字母混淆插入订单主表拿到自增id遍历购物车明细批量插入订单明细表扣减库存UPDATE dish SET stock stock - ? WHERE id ? AND stock ?清空Session里的购物车返回订单号跳转到支付模拟页。这十步里第5、6、7、8四步必须处于同一个事务里。如果只插了主表、明细表插入失败主表订单就是一条“孤儿订单”如果下单成功但库存没扣商品就可能超卖。早餐店虽然单量不大但逻辑上不能有这种漏洞。我用最朴素的方式管理事务在DBUtil里封装getConnection()、beginTransaction()、commit()、rollback()然后在Service层手动控制Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 插入订单主表、明细表、扣库存 DBUtil.commit(conn); } catch (Exception e) { DBUtil.rollback(conn); throw new RuntimeException(下单失败, e); } finally { DBUtil.release(conn); }由于DAO层的每个方法内部都会独立拿连接和释放连接开事务时必须用一个外部传入的连接。方法签名设计成insertOrder(Connection conn, Order order)或者在DAO内部增加一个重载方法透传连接。新手在这个地方很容易犯的错是Service层开启事务后调用DAO方法DAO又自己DriverManager.getConnection()新拿了一个连接导致事务根本没作用在同一个数据库会话上。务必保证整个业务链路用的是同一个Connection。订单状态流转我设计为0待支付 → 1已支付/备餐中 → 2配送中 → 3已完成或者0待支付 → 4已取消。后台管理员操作按钮根据当前状态显示对应的下一步操作。这个状态机的实现原则是状态只能往后走不能回退例如不能从“配送中”改成“待支付”否则财务数据就乱了。3.5 后台订单管理与营业额统计后台管理员的首页是一个轻量仪表盘今日订单数、今日营业额、待处理订单数、菜品销量Top5。这些数据如果用一条SQL硬拼会很痛苦我的做法是拆成多个SQL聚合查询分别查COUNT(*)、SUM(total_amount)等。营业额统计有一个大坑订单金额的累加时机。如果统计口径是“订单创建时间”那就要排除已取消状态如果统计口径是“订单完成时间”那所有状态都得列入“待支付”但未支付的也算异常。我最终采用的口径是“只统计状态为已支付、配送中、已完成的订单”把待支付和已取消排除掉。SQL写法类似SELECT DATE_FORMAT(create_time, %Y-%m-%d) AS day, COUNT(*) AS order_count, COALESCE(SUM(total_amount), 0) AS revenue FROM orders WHERE status IN (1, 2, 3) AND create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d) ORDER BY day;COALESCE必须加上——某一天没有任何订单时SUM返回null页面直接显示空白用0填充才合理。统计图不建议后端生成图片太重了。我的方案是后台JSP页面把统计结果转换成JSON数组用原生JavaScript画简单的柱状图或者引入轻量Chart.js库。早餐店老板最关心的是“今天卖了多少单”“哪几样卖得好”柱状图和排名列表就够了不需要炫酷的3D图表。4. 前端交互与JavaScript细节 —— 让页面活起来的小技巧4.1 表单校验从正则到提交拦截外卖系统的表单主要集中在登录、注册、下单这三个场景登录只需要用户名密码非空但注册和下单必须做格式校验。我在前端用JavaScript做首道防线在Servlet端做第二道防线绝不信前端的校验结果。手机号校验是最常见的需求我用一个正则function isValidPhone(phone) { var reg /^1[3-9]\d{9}$/; return reg.test(phone); }这个正则代表第一位是1第二位在3到9之间后面跟着9位数字总计11位。它基本覆盖了目前国内用户手机号的规律能在用户输入错误时立刻提示减少无效请求到达服务器。但正则只是“格式校验”不是“真实验证”——如果项目需要验证号码真的存在那就要接短信验证码服务早餐店系统用不上这个级别。下单表单里的“期望送达时间”必须用JavaScript校验不能选过去的日期不能选今天之前的时间。我的做法是用new Date()获取当前时间戳然后和输入框的值做对比时间早于当前时间直接弹框拦截。4.2 AJAX局部刷新消息提示和购物车角标JSP页面默认是同步刷新但有几个场景用AJAX体验能提升一个档次。最典型的是“加入购物车”用户点一下“加入购物车”按钮如果整页刷新页面滚动位置会重置用户要重新找到刚才那个菜很烦。我改成用fetch或XMLHttpRequest异步提交返回JSON前端根据结果更新页面上的“购物车商品数量”角标同时弹一个提示条“已加入购物车”。原生JavaScript里可以用这个方式实现AJAX请求function addToCart(dishId) { fetch(ctx /cartServlet?actionadddishId dishId, { method: GET, credentials: same-origin }) .then(function(resp) { return resp.json(); }) .then(function(data) { if (data.success) { document.getElementById(cartCount).textContent data.cartCount; showMsg(已加入购物车); } else { showMsg(data.message || 加入失败); } }); }这里有个细节要注意AJAX请求同样会被LoginFilter拦截。如果用户没登录就直接点“加购”返回的不是JSON而是一个重定向到登录页的HTML页面前端解析resp.json()会报错。我的处理方案是在Filter里对AJAX请求做单独分支判断通过X-Requested-With请求头识别未登录时返回{success:false,message:请先登录}前端的提示条会展示这个信息。购物车角标更新的本质是后端addToCart逻辑执行完成后计算cart.size()把数值放进JSON返回。前端拿到后直接textContent赋值这样不用刷新页面就能看到角标变化。4.3 图片坐标交互和旋转的实用技巧前面提到了HTML map做图片热区这里再补充一种方案用JavaScript监听图片点击位置实现更复杂的交互。比如菜品详情页有一张“套餐搭配示意图”用户点击图上的某个位置系统判断点击点落在哪个菜品的圆形区域内然后弹出该菜品的详情卡片再点一次可以加购。核心代码是这样的document.getElementById(dishMap).addEventListener(click, function(e) { var rect e.target.getBoundingClientRect(); var x e.clientX - rect.left; var y e.clientY - rect.top; // 判断坐标是否落在某个圆内比如圆心(100,80) 半径40 var dx x - 100; var dy y - 80; if (dx * dx dy * dy 40 * 40) { window.location.href ctx /dishServlet?actiondetailid3; } });判断一个圆要计算距离平方这是初中几何——点到圆心的距离小于半径就在圆内。这套逻辑比HTML map灵活在与后端数据联动菜品坐标可以存在数据库里前端循环读取后在canvas或div上动态绘制热区而不需要改HTML代码。另外提一个小技巧有些早餐店会拍竖版图片但页面上想展示横版的效果。传统做法是后端处理图片旋转其实前端一行JavaScript就能搞定旋转例如document.getElementById(menuImg).style.rotate -90deg;CSS的rotate属性可以直接用角度值注意设置旋转变换后也要配合transform-origin调整旋转中心不然图片会绕着左上角转视觉上偏了。这类前端小技巧在处理一些手机拍摄的早餐实拍图时特别实用。4.4 跨浏览器兼容的几个注意点现在的浏览器都是现代浏览器了大部分标准API都能直接用但做JavaWeb课程设计时要考虑用户可能用IE或者老内核浏览器访问。兼容性风险最高的有四个地方fetch、Promise、let/const、Arrow Function。如果项目没有引入任何框架和库我建议写原生JavaScript时采用ES5语法或者用最基础的XMLHttpRequest封装。比如上面的addToCart函数改成原生写法var xhr new XMLHttpRequest(); xhr.open(GET, ctx /cartServlet?actionadddishId dishId, true); xhr.onreadystatechange function() { if (xhr.readyState 4 xhr.status 200) { var data JSON.parse(xhr.responseText); ... } }; xhr.send();这里不是让你退回老技术而是在选型阶段就想清楚目标用户是谁。如果这个系统只是校内课程设计运行环境就是Chrome那用fetch完全没问题。如果是要交付给某个早餐店实际使用店里的电脑可能是十年前的老机器、浏览器从来没升级过这时候ES5的兼容性优势立刻体现出来。5. 本地环境部署实操 —— IDEA MySQL 从零跑通项目5.1 MySQL 安装与版本选择这个项目用哪个版本的MySQL合适我推荐5.7 LTS版本例如5.7.44原因有三点5.7还是目前大量高校和中小公司的稳定主力版本网上教程最多和8.0相比5.7的连接驱动配置相对简单不会碰到8.0的caching_sha2_password验证插件兼容问题早餐外卖项目的SQL语句没有8.0特有的窗口函数需求完全不需要硬上8.0。但如果你电脑上已经装了8.0也不用卸掉重装。只要在JDBC连接串里额外配置两项useSSLfalse和allowPublicKeyRetrievaltrue。8.0默认使用caching_sha2_password认证老版本的MySQL驱动5.1.x系列连接会报“Public Key Retrieval is not allowed”这个错加上allowPublicKeyRetrievaltrue就能解决。安装MySQL时有两个大坑必须提前说。第一个是初始密码Windows用ZIP压缩包方式安装时解压后执行mysqld --initialize-insecure会生成一个root空密码这样第一次登录不用费劲找临时密码直接打mysql -uroot就能进。第二个是my.ini配置文件里的字符集default-character-set要配合MySQL 5.7的写法服务端设置character-set-serverutf8mb4客户端连接时在连接串里追加useUnicodetruecharacterEncodingutf8否则JSP页面显示中文全是问号。Linux服务器上部署时RPM包安装有一个常见问题——MySQL 5.7官方仓库有时只显示最新小版本比如搜索“mysql 5.7.44”可能找不到对应RPM包。我的建议是直接到MySQL官方下载站挑mysql-community-server-5.7.44-1.el7.x86_64.rpm这种完整包安装不要依赖yum源自动解析版本。5.2 IDEA 配置 Tomcat 与项目结构在IDEA里跑JSPServlet项目新手被卡住的地方新建项目时选错了类型。正确姿势是创建一个普通的“Java Enterprise”项目勾选Web Application模板然后配置Tomcat。如果你手里是已经写好的项目代码导入时要注意三个地方第一项目必须包含webapp目录且该目录被正确标记为Web资源目录。IDEA里右键webapp→ “Add Framework Support” → 选择“Web”是可以补救的。第二WEB-INF/web.xml文件必须存在Servlet全注解开发时代甚至可以没有web.xml但IDEA的Artifact打包默认依赖这个文件。第三Tomcat的Lib目录要确认IDEA里配置Tomcat的“Server”选项时“Libraries”标签页如果没有把servlet-api.jar加进来所有Servlet类都会编译报错找不到HttpServlet。5.3 JDBC 连接与SSL错误处理JDBC连接MySQL的代码是我见过报错频率最高的地方。核心连接串如下以MySQL 5.7为例private static final String URL jdbc:mysql://localhost:3306/food_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD yourpassword; Class.forName(com.mysql.jdbc.Driver); Connection conn DriverManager.getConnection(URL, USER, PASSWORD);useSSLfalse几乎必写。如果你装的是MySQL 8.0却下载了5.x的驱动如果不加这个参数会报“SSL connection error: java.net.SocketException: Connection reset”看起来像网络问题实际是SSL握手失败。serverTimezoneAsia/Shanghai也要写因为MySQL 5.7之后驱动要求明确时区不写会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这种乱码时区错误。驱动包的jar文件放置位置也是入门常见问题正确的放法是复制到webapp/WEB-INF/lib目录下而不是项目根目录某处。IDEA里虽然可以通过“Add as Library”让编译通过但Tomcat部署时如果驱动只在编译classpath里、不在WEB-INF/lib运行时就会报ClassNotFoundException: com.mysql.jdbc.Driver。保险起见同时放到外部的Tomcat lib目录也可以但我更推荐跟随项目打包这样换一台电脑拉代码不会丢依赖。5.4 部署过程中最常见的几个404/500问题部署跑起来之后如果页面出现404或500先别慌按以下列表排查大部分情况是低级配置问题现象可能原因解决方案浏览器访问localhost:8080显示404 Tomcat首页项目没有正确部署到Tomcat的webapps目录IDEA里检查Artifact输出或者直接把项目打成war包放到Tomcat/webapps下Servlet访问时404注解WebServlet的URL路径写错或web.xml里没有正确配置映射确认注解路径以/开头比如WebServlet(/dishServlet)访问时500 ClassNotFoundExceptionJDBC驱动jar不在WEB-INF/lib下把jar复制进webapp/WEB-INF/lib后Rebuild Artifact中文乱码页面编码、请求编码、数据库编码不一致统一设置JSP页面pageEncodingUTF-8Servlet里request.setCharacterEncoding(UTF-8)Filter全局接管访问jsp页面直接变成源码下载Tomcat没有配置JSP支持检查是否误把JSP文件放到了webapp/static或直接用浏览器访问了未编译的源文件报错端口被占用Tomcat默认8080被其他程序占用用netstat -ano还有一个特别隐蔽的问题IDEA重新部署项目时常常报“Address localhost:8080 is already in use”。这是因为之前部署的Tomcat实例没有正常关闭杀进程时注意看java.exe进程直接全杀容易误伤IDEA自身我建议用netstat -ano查出监听8080端口的PID再taskkill /PID xxx /F。6. 实操过程实录 —— 跑一遍核心流程看看每一步做了什么6.1 从建库建表到后台发布菜品的完整流程拿一台全新的Windows机器举例整个流程分七个步骤第一步安装MySQL并启动服务。ZIP包解压后在bin目录执行mysqld --install和net start mysql然后命令行执行mysql -uroot -p登录执行建库语句CREATE DATABASE food_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;。选择utf8mb4而不是utf8的原因是utf8mb4能存emoji和生僻字早餐店铺名和菜品里可能出现的特殊符号不会变成乱码。第二步执行表结构SQL建表。依次执行分类表、菜品表、用户表、订单主表、订单明细表的建表语句然后插入测试数据。比如INSERT INTO category(name, sort_order) VALUES (粥品, 1), (煎饼, 2), (豆浆油条, 3); INSERT INTO dish(category_id, name, price, stock, status) VALUES (1, 皮蛋瘦肉粥, 6.00, 50, 1), (2, 加蛋煎饼, 8.00, 30, 1), (3, 豆浆, 2.00, 100, 1);第三步在IDEA里创建JavaWeb项目配置好Tomcat第四步编写实体类。Dish实体类要包含所有表和前端展示需要的字段注意originalPrice和categoryName这类冗余展示字段可以加上——它对应分类表的一对一关联通常用JOIN查询时在DAO层手动组装。第五步编写JDBC工具类和DAO层第六步编写Servlet层和JSP页面第七步启动Tomcat访问http://localhost:8080/food_web/查看菜单页。后台发布菜品的流程管理员登录后进入/admin/dish_list.jsp点“新增菜品”填写基本信息。这里图片上传是个必做的功能我建议先把图片文件保存到webapp/upload目录数据库只存“上传后返回的文件名”。实际上传用ServletFileUpload解析multipart表单一个隐藏坑是Tomcat的默认post体大小限制——maxPostSize默认2MB早餐菜品图片一般不大但也要注意如果上传超过2MB的图片会直接抛异常需要到conf/server.xml的Connector标签里设置maxPostSize-1-1表示不受限。6.2 顾客端模拟下单流程顾客打开首页看到分类导航栏和菜品列表点“加入购物车”按钮。这里需要注意的是未登录用户点击加购时Filter会拦住并跳到登录页但URL参数丢失登录后不会自动回到加购动作。我的改进方案是跳转登录页前把原始请求URI存入Session登录成功后sendRedirect回去session.setAttribute(returnUrl, request.getRequestURI() ? request.getQueryString());这样用户登录一次就能回到刚才浏览的位置体验好不少。登录后继续加购右上角购物车角标从0变1再点进去看到购物车明细列表修改数量后页面底部实时刷新总金额——这一项我用JS计算因为每个菜的小计和总价都能从前端数据拿到不用每次改动都请求后端。然后点击“去结算”填写收货人、手机号、地址、期望送达时间。时间组件我用input typedatetime-local好处是浏览器自带原生选择器后端解析时用SimpleDateFormat按yyyy-MM-ddTHH:mm格式处理。提交订单后页面跳转到支付模拟页显示订单号和应付金额点击“模拟支付”按钮Ajax请求后端把订单状态从0改成1页面显示“支付成功店铺备餐中”。整个流程在后端做了什么提交时事务里插了主表和明细表扣了库存清空购物车支付时只更新订单状态字段一个UPDATE搞定。早餐店场景不需要真实接支付接口这个模拟流程已经把业务闭环打通了。6.3 管理员端订单处理流程管理员登录后看到待处理订单列表每个订单显示订单号、用户、餐品明细、总金额、期望送达时间。点击“确认接单”按钮后端把status 1改成status 2。再点“完成配送”status 2改成status 3。这里有一个实际操作中的体会管理员页面最好用定时刷新或手动刷新方式更新列表不用做WebSocket实时推送。早餐店高峰时段可能同时来十几个订单管理员每接一单刷新一下页面完全够用WebSocket的实时性在这个场景里是伪需求还引入一堆复杂度。如果真想提升体验可以加一个前端定时器每分钟自动location.reload()或者用AJAX轮询刷新订单数量角标实现简单效果也尚可。6.4 数据统计校验后台统计页要显示每日营收曲线和热销菜品榜。菜品类销量我依赖订单明细表里的quantity字段SELECT dish_name, SUM(quantity) AS total_sales FROM order_item WHERE order_id IN ( SELECT id FROM orders WHERE status IN (1,2,3) ) GROUP BY dish_name ORDER BY total_sales DESC LIMIT 5;这种SQL在数据量小的时候性能没问题。等订单积累到万级子查询的性能瓶颈会出现优化方向是把统计逻辑拆到独立报表表里每天夜里定时汇总。但在课程设计和创业初期完全够用不必过度设计。7. 常见问题与排查技巧速查表7.1 高频报错的一线解决方案列举一下我实际跑这个项目时遇到、以及帮别人排查过的最典型问题直接按表格查就行症状根因处理方式java.sql.SQLException: Access denied for user rootlocalhost密码错误或root账号授权问题在命令行用mysql -uroot -p重设密码或执行ALTER USER rootlocalhost IDENTIFIED BY 新密码;The server time zone value ... is unrecognized连接串没指定时区连接串加serverTimezoneAsia/ShanghaiMySQL 8.0时加useSSLfalseallowPublicKeyRetrievaltrue中文乱码多层编码不一致Filter里统一request.setCharacterEncoding(UTF-8)JSP顶部pageEncodingUTF-8MySQL表字段用utf8mb4表单提交后原子性问题没有开启事务Service层包事务确认DAO方法复用同一个ConnectionTomcat运行后无法访问项目页面Artifact配置错误IDEA里Project Structure → Artifacts → 确保输出类型为war explodedDeployment里Application context填/MySQL 8.0连不上报Public Key Retrieval is not allowed8.0默认认证插件连接串加allowPublicKeyRetrievaltrue或创建MySQL用户时指定mysql_native_password7.2 SQL层面的隐蔽坑早餐外卖系统的SQL并不复杂但几个隐蔽问题非常值得记录。第一个是DELETE FROM orders WHERE id?这种删除订单的SQL一旦订单有明细表关联删除主表会产生孤儿明细数据。项目里我的策略是不做物理删除用status4已取消标记后台列表默认过滤掉但数据完整保留。第二个是分页排序混乱问题如果一个页面上菜品数据多次插入没有明确的ORDER BY idMySQL返回顺序可能随机且不稳定。所以我分页SQL统一加ORDER BY id ASC避免用户翻页时看到菜品顺序跳变。第三个是COUNT(*)和COUNT(1)的选择——在这类小项目里性能差异可忽略但注意COUNT(column)会忽略NULL列统计订单数量时如果列有NULL值就容易数错直接用COUNT(*)最稳妥。7.3 前后端联调时的数据格式问题后端Servlet返回数据给前端JavaScript时通常用JSON格式。我用的工具是阿里的Fastjson或者Google的Gson在Java里JSON.toJSONString(resultMap)然后通过response.getWriter().write(json)输出。JSP的script块里接收时有一个编码陷阱如果JSP本身contentTypetext/html; charsetUTF-8但Servlet响应的Content-Type没设置前端JSON.parse可能因为编码问题解析失败。稳妥做法是Servlet端每次返回JSON都设置response.setContentType(application/json;charsetUTF-8);前端拿到中文后不用手动转码JSON标准要求UTF-8。8. 从JSPServlet到框架的思维迁移如果这个系统你已经完整写过一遍我建议你做三个后续的小实验用最小的成本感受框架演进思路。第一个实验把DishServlet的一个action方法改成Spring MVC的RequestMapping写法对比一下URL映射和参数获取的异同。你会发现Spring MVC的RequestParam本质就是request.getParameter()的封装ModelAndView本质就是request.setAttribute() forward的封装。第二个实验把JDBC手动事务改成声明式事务感受一下Transactional为什么能减少重复代码。手写事务要每次开连接、提交、回滚声明式事务只用加一个注解底层是用AOP动态代理做的同一件事。第三个实验把Session购物车改成Redis缓存购物车对比Session方案和独立缓存方案的差异。Session方案天然绑定单台服务器Redis方案可以让用户请求打到不同后端节点而不丢购物车数据。这个对比做完后你会对无状态设计和水平扩展有切身理解。每次从老技术迁移到新技术时试着在代码注释里标注“这个注解背后做了哪些事如果不用框架我会怎么写”——坚持这样一个习惯技术底子会打得很扎实。我见过太多只会在Spring Boot里写RestController、但连HTTP请求是怎么被Servlet容器接住的人。早餐外卖项目这种“裸写”机会其实是性价比极高的自我训练。最后分享一点个人看法技术选型要看场景课程设计和毕业设计写JSPServlet不是落后而是用最小的复杂度看清整个Web请求链路。你在IDEA里按部就班搭一次这个系统把Session透传、事务边界、Filter拦截这几块真正吃透再去学Spring Boot效率能翻倍。上面这些坑我基本都踩过一遍写下来算是给后来者省点时间照着做应该能少走不少弯路。