Java Web点餐管理系统开发实战:JSP/Servlet、JDBC事务与数据库设计全解析
如果说让我给Java Web初学者推荐一个既能完整覆盖主流技术点、又不会复杂到让人中途放弃的练手项目我一定会选点餐管理系统。原因很简单点餐这个业务场景天然就有“前台点菜、后台管理、订单流转、库存变动”这一整条完整链路。它不像博客系统那样只涉及简单的增删改查也不像电商系统那样动辄就要考虑秒杀、分布式事务。点餐系统刚好处在一个“复杂度刚好够训练思维”的位置——做完了它你对Session会话管理、JDBC事务、表结构设计、请求转发与重定向这些Java Web核心知识的理解会从“背概念”变成“真会用”。这篇文章就把我从零设计和实现一个基于Java点餐管理系统的完整过程写出来。包括方案选型为什么这么定、数据库表是怎么拆的、核心流程的代码怎么组织、还有我在实际开发中踩过的坑和排查思路。如果你正在做类似的课设、毕设或者单纯想找个项目练手这篇内容可以直接“抄作业”。1. 整体设计与技术选型思路1.1 这个系统到底要解决什么问题在动手写代码之前得先想清楚这个系统是个什么东西。点餐管理系统本质上解决的是餐饮门店的两个核心痛点一是前台点餐效率。纸质菜单点餐服务员要记录菜名、数量再跑到后厨下单人多的时候容易漏单、错单。二是老板对经营数据的掌握。今天卖了多少单、哪道菜卖得最好、还有多少库存这些如果全靠人工统计耗时且容易出错。所以系统拆成两端的模块会比较清晰顾客端注册登录、浏览菜品、按分类筛选、加入购物车、提交订单、查看自己的历史订单。管理端菜品分类管理、菜品上下架、库存数量维护、处理顾客订单接单/完成/取消、查看订单统计。这样拆分的好处是职责清楚数据库表的边界也容易划定。我当时画功能结构图的时候就把所有功能列成两张表顾客端一张、管理端一张然后一个功能对应一个Servlet和一组JSP页面开发节奏非常快。1.2 技术选型为什么这个项目用JSP/Servlet而不是Spring Boot这是很多人会纠结的问题。现在企业里用Spring Boot的确实多但对于一个用来理解Web应用本质的点餐系统项目我反而建议用JSP Servlet JDBC这套经典组合理由有三点。第一JSP/Servlet让你亲眼看到HTTP请求是怎么走的。一个请求从浏览器发出经过Servlet接收、调用业务逻辑、访问数据库、把结果塞进request域、转发到JSP渲染页面——这条链路是Web开发最底层的骨架。如果一开始就上Spring Boot这些细节被框架遮掩出了Bug你都不知道去哪查。第二这个项目的复杂度撑不起框架的价值。点餐系统的核心业务无非是登录、下单、查询这几件事用JDBC手写SQL完全可控。如果引入MyBatis、Spring MVC配置量反而不小对初学阶段来讲是干扰项。第三从Servlet到Spring系列框架是一个自然的进阶阶梯。先手写过Filter、自己处理过事务后面再看Spring的声明式事务你会顿悟它帮你省了多少事而不是觉得那是个魔法。当然如果你已经有Java Web基础只是想看看点餐系统的业务设计那完全可以用Spring Boot MyBatis重写这套逻辑核心的表结构设计、业务流程图都是一样的。1.3 分层的包结构设计我实际用的包结构是这样的com.restaurant ├── entity // 实体类对应数据库表 │ ├── User.java │ ├── Category.java │ ├── Dish.java │ ├── CartItem.java │ ├── Order.java │ └── OrderItem.java ├── dao // 数据访问层只负责SQL操作 │ ├── UserDao.java │ ├── CategoryDao.java │ ├── DishDao.java │ └── OrderDao.java ├── service // 业务逻辑层处理业务流程和事务 │ ├── UserService.java │ ├── DishService.java │ └── OrderService.java ├── servlet // 控制层接收请求调用服务 │ ├── LoginServlet.java │ ├── MenuServlet.java │ ├── CartServlet.java │ └── OrderServlet.java ├── filter // 过滤器编码处理、登录校验 └── util // 工具类DB连接、字符串处理这个三层结构虽然朴素但符合MVC的基本思想。JSP只负责展示Servlet只负责流程调度真正的业务判断放在Service层。前端的参数校验和后端的权限拦截是两回事后端必须要做二次校验这一点在设计初期就要定下来。1.4 开发环境与版本选择我用的环境是老少皆宜的组合JDK 8、Tomcat 9、MySQL 5.7IDE用Eclipse或IDEA都行。JDK 8的主要原因是兼容性好教程多遇到问题搜起来方便。数据库驱动用mysql-connector-java 8.0.x连接URL记得带上时区参数否则很可能出现时间相关的诡异报错。注意如果用JDK 1.8 Tomcat 9的搭配Servlet版本是4.0写注解方式WebServlet是可以的不用再去改web.xml配Servlet映射清爽很多。2. 数据库设计把表结构一次想清楚2.1 角色模型一套表搞定还是拆两张表点餐系统的用户分顾客和管理员两种角色。很多教程喜欢把这两种角色放同一张user表用role字段区分比如role1是管理员、role2是顾客。我实际做下来觉得这个方案在这个项目里完全够用不用拆成admin表和customer表。为什么因为这两个角色共用的核心字段是账号、密码、昵称而管理员比顾客多的只是权限标识并没有一套完全不同的数据属性。拆两张表反而让登录逻辑变复杂——先查一张表再查另一张空表还要转换判断。所以user表加一个role字段登录时一次查到用户再看role决定跳转到顾客首页还是管理后台清晰省事。建表SQL大致是这样CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), role INT DEFAULT 2 COMMENT 1管理员 2顾客, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );密码字段我建议存MD5或SHA256加盐后的哈希值绝不存明文。这个项目虽然以学习为主但好的习惯要从练手阶段养成。加盐的格式可以是“盐值密码”拼起来再做哈希盐值也存到库里的另一个字段校验时取出来重新算一遍再比对。2.2 菜品分类与菜品表的设计菜单模块是点餐系统的门面它的表结构设计直接影响到前台展示的逻辑。我设计了两个主表加一张关联关系字段分类表 categoryid、name、sort排序权重、create_time。前台按分类展示菜品后台管理分类的增删改。菜品表 dishid、category_id、name、price、image、description、stock库存数量、status1上架 2下架、create_time。价格字段用DECIMAL(10,2)千万不要用float或double。原因很简单浮点数在二进制里无法精确表示比如0.1 0.2可能等于0.30000000000000004。虽然展示层看着差不多但订单金额累计时会出现极小误差做财务报表时很难看。用DECIMAL既是数据库层面的最佳实践也能帮你少踩很多坑。菜品表和分类表通过category_id关联在JSP页面上展示某分类下的菜品其实就是一条带WHERE条件的查询语句这么简单。关键的SQL是SELECT * FROM t_dish WHERE category_id ? AND status 1 ORDER BY id DESC2.3 订单主表和订单明细表为什么要拆开这是整个数据库设计里我最想讲的部分。很多初学者容易把订单设计成一张大宽表一个字段存菜名、一个字段存数量、一个字段存总价看着简单实际上问题很多。比如一桌顾客点了三道菜如果都存在一条记录里菜名要怎么拼用逗号分隔那下次想统计“哪道菜被点了多少次”难道要拆字符串而且每道菜的价格可能不同订单总价怎么算正确做法是拆成两个表订单主表 orderid、order_no订单编号、user_id、total_amount、status1待处理 2已完成 3已取消、create_time。订单明细表 order_itemid、order_id、dish_id、dish_name、price、quantity、subtotal。这个设计就像超市小票主表是小票抬头单号、总金额、时间明细表是小票上的每一行商品、单价、数量、小计。查询某些问题就变得非常自然查订单列表时只查主表点开某一单时再查明细表统计热销菜品时只需要对明细表按dish_name做GROUP BY。订单编号order_no建议单独生成不要用自增主键直接展示给用户。原因一是自增主键透露了系统数据量二是订单号通常需要一定的可读性。我当时用的生成规则是“yyyyMMddHHmmss 三位随机数”再拼上用户ID后缀虽然简单但单店场景下完全够用。如果你们有更规范的需求可以再研究雪花算法这里不展开。2.4 库存联动设计上的一个关键考虑菜单里的每道菜都对应一个库存数。顾客下单的时候要扣库存但这里有一个痛点如果哪天下单流程出错了比如用户提交订单后没付款直接把库存扣了菜品就会虚少。如果先不扣库存订单提交了才扣又可能出现超卖。我在这个项目里用的策略是顾客提交订单时先校验库存并锁行更新如果库存不够就直接提示“菜品售罄”。更新库存的SQL写成原子的条件更新UPDATE t_dish SET stock stock - 1 WHERE id ? AND stock 0这个SQL利用的是数据库行锁和条件的原子性天然避免超卖比先查出来再判断再更新更安全。后台管理员在“菜品管理”里也能手动增加库存处理实际备货。3. 核心模块实操从登录到下单一口气跑通3.1 登录会话与会话安全登录模块虽然简单但有三个细节我建议你注意。第一个是密码加密。刚才提过用MD5加盐存储即可。这里补一个具体写法注册时生成一个随机字符串作为盐存到user表密码字段存的是MD5(盐 明文密码)的结果。登录校验时先查user得到盐再算一次MD5比对。这样就算数据库泄露了攻击者也很难直接拿到用户明文密码。第二个是登录状态的保持。登录成功后把用户对象放进Sessionsession.setAttribute(user, user)。之后顾客访问购物车、下单接口时通过过滤器判断Session里有没有user对象没有就跳转到登录页。管理员的访问则在同一过滤器里加一层角色判断只有role1的能访问/admin/路径。public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) res; HttpSession session request.getSession(false); Object user (session ! null) ? session.getAttribute(user) : null; if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, res); }第三个是“记住登录状态”的思路扩展。这个项目用Session够用但你要留意Session是服务端内存持有的如果服务重启或者部署了多台实例Session就失效了。真实项目里一般会考虑用Redis存Session或用JWT做无状态会话但在单体Tomcat部署场景Session就是标准答案。把它从小做到大实现思路是一脉相承的。3.2 购物车与下单流程这是整个项目的核心逻辑购物车是临时性数据不需要落地到数据库存Session里面就好。每个购物车条目包含菜品ID、菜名、单价、数量。我在实体类里定义了一个CartItem把菜品对象和数量封装在一起检查购物车时就能顺便把菜品的库存、上下架状态一起带着。下单流程的核心序列是这样的用户点击“提交订单”请求到OrderServlet。OrderServlet调用OrderService的createOrder(userId, cart)方法。Service层开启事务从连接池获取Connection设置setAutoCommit(false)。遍历购物车对每个菜品执行原子扣库存SQL。如果返回更新行数小于1说明库存不够整体回滚提示“某菜品库存不足”。插入订单主表拿到自增主键。遍历购物车把每个菜品作为明细插入订单明细表。计算总金额菜单价 × 数量累加更新到订单主表。提交事务清空Session中的购物车。这里最关键的是第4到第8步必须在一个事务里。我见过很多初学版本的点餐系统下单就是先减库存、再插订单两步之间没有任何事务控制。万一插订单时抛异常了库存已经扣了顾客却没订上餐后台库存还神秘消失排查起来非常痛苦。下面是我实际写的一个简化版本你看完就能理解事务如何裹住多表操作public Order createOrder(User user, ListCartItem cart) throws SQLException { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); OrderDao orderDao new OrderDao(); // 1. 扣库存 for (CartItem item : cart) { int rows orderDao.deductStock(conn, item.getDishId(), item.getQuantity()); if (rows 1) { conn.rollback(); throw new RuntimeException(菜品[ item.getDishName() ]库存不足); } } // 2. 生成订单主表 Order order new Order(); order.setOrderNo(OrderNoGenerator.generate()); order.setUserId(user.getId()); double total cart.stream().mapToDouble(i - i.getPrice() * i.getQuantity()).sum(); order.setTotalAmount(total); order.setStatus(1); orderDao.insertOrder(conn, order); // 3. 生成订单明细 for (CartItem item : cart) { orderDao.insertOrderItem(conn, order.getId(), item); } conn.commit(); return order; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }注意事务一旦开启Connection就不能随便关闭必须等事务提交或回滚后再释放。所以case里我特意把“设置自动提交为true”放回了finally防止连接池里残留一个非自动提交的连接导致后续请求查不到数据或死锁。3.3 订单结算与订单状态流转后台的订单管理是管理员每天都要用的页面。我的订单列表页展示主表信息每条订单后面放“查看详情”“完成”“取消”三个按钮。详情页用订单ID查明细表把每一道菜和价格渲染成表格。订单状态我固定了三个值1待处理、2已完成、3已取消。后台“完成”操作把status从1改成2“取消”则把status改成3。要注意的是取消订单时考虑要不要回补库存——如果顾客下单后不想吃了后厨还没做那库存就应回补。这个业务逻辑其实很自然真实场景中根据门店规则调整即可我在实现里默认会回补。顾客端的历史订单查询则是order表按user_id过滤再排序展示。这里有一个小细节顾客只应该看到自己的订单SQL里一定要加user_id条件不能让顾客请求一个“所有订单”的接口然后前端过滤。4. 常见问题与排查技巧实录4.1 中文乱码每个Web初学者都会遇到乱码问题在JSP/Servlet项目里几乎人人都会见一回。表现形式是页面显示中文正常但提交到数据库后变成“”或者页面显示火星文。原因基本逃不出三个环节第一是请求编码。POST请求的参数编码取决于请求体的字符集如果你没设置Tomcat默认用ISO-8859-1解析中文必乱。解决方式有两种一是GET方式改Tomcat的URIEncodingUTF-8二是POST方式在Servlet开头写request.setCharacterEncoding(UTF-8)。我更推荐用Filter统一处理毕竟每个Servlet写一遍太丑。public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletRequest request (HttpServletRequest) req; request.setCharacterEncoding(UTF-8); res.setCharacterEncoding(UTF-8); res.setContentType(text/html; charsetUTF-8); chain.doFilter(req, res); }第二是响应编码。JSP页面顶部要写pageEncodingUTF-8同时确保浏览器解析也是UTF-8。如果这两处不一致页面会出现方框一样的乱码。第三是数据库连接。JDBC连接串里要加上characterEncodingutf8。MySQL 5.7的连接URL形如jdbc:mysql://localhost:3306/restaurant?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai排乱的思路从外到里先看页面上是否乱码再看数据库表里的数据是否乱码。表里好的、页面乱问题在响应环节表里都乱问题在请求编码或连接串。4.2 超卖问题两个人同时抢最后一份菜这个Bug如果不是并发测试平时很难遇到。简单说就是两个人同时下单数据库里同一道菜stock1两个请求都先查出库存1都判断“还有库存”然后都扣减最终库存变成-1。这就不是库存不准确的问题是白送菜的问题。解决方案就是我前面提到的条件更新SQLUPDATE t_dish SET stock stock - ? WHERE id ? AND stock ?。这条语句的执行是原子性的数据库在行级别上会加锁第二个请求执行时发现stock已经是0更新行数为0Service层就可以抛异常回滚。这个方案虽然简单但每次更新都会对菜品所在行加锁并发高时会有一定锁竞争。对点餐场景来说菜品数量不会特别大而且一道菜被同时高频下单的概率有限这种强度完全够用。4.3 请求转发和重定向别用混写登录模块时很容易踩到这个坑。登录成功后是用request.getRequestDispatcher(/index.jsp).forward()还是response.sendRedirect(/index.jsp)这两种方式看起来都跳到下一个页面但行为完全不同场景转发 forward重定向 redirect浏览器地址栏不变还是旧地址变为新地址请求次数1次2次request域数据能保留不能保留适用场景Servlet把数据塞request后跳JSP处理完POST后跳转防止表单重复提交如果你登录后要把用户信息塞进request再跳转到首页用forward很方便。但如果用户登录成功后刷新页面浏览器会把上次的POST请求再发一次结果就是“刷新一下又登录了一次”。所以我一般会这么处理POST登录成功后用redirect跳转到首页Servlet首页Servlet再查数据并forward到JSP。这样刷新页面时只会重新请求GET首页不会再重复提交登录表单。4.4 导航栏高亮、状态显示这类小功能也别忽略很多人做管理系统时会忽略一个体验细节当前在哪个菜单分类、订单处于什么状态页面上的导航栏或标签页没有高亮反馈。这在技术上其实很简单就是把“当前选中的分类ID”或“当前状态”塞进request然后在JSP里用if判断输出一个classactive。我建议你在开发时就顺手做掉因为这种细节直接关系到一个系统看起来是“能跑的Demo”还是“能用的成品”。具体做法是在JSTL的c:if标签里判断c:if test${param.categoryId dish.categoryId}active/c:if但如果请求参数和当前数据不是同一标准判断会失效。我的经验是Servlet在转发时统一把当前选中项设成request属性JSP里的判断全部以这个属性为准避免拿param反复比较导致边界条件漏判。4.5 数字格式化与金额计算的精度问题前端JSP页面展示金额时最好用fmt:formatNumber标签把金额格式化成两位小数。否则一旦出现数据库里存的是19.90页面显示成19.9顾客看着别扭打印的小票也不规范。后端计算总金额时用double做累加容易出精度问题建议用BigDecimal。具体做法是先把购物车里的菜品单价new BigDecimal(String.valueOf(price))再multiply数量最后add求和。虽然代码稍啰嗦但精度可控不会出现0.1 0.2 0.30000000000000004这种尴尬。5. 写在最后做这个项目最值钱的体会点餐系统做下来我最深的感受是这个项目真正的价值不在于能跑通下单一顿饭而在于它逼着你把“并发安全”“事务一致性”“分层职责”这些书本上的概念变成实实在在的代码习惯。比如下单减库存这件事如果你每天只做增删改查你是不会意识到要加事务的。但点餐系统里扣库存、插订单、插明细三步一环扣一环少一步就会出现脏数据这种“被业务逼出来的严谨”才是项目经验的本质。整个项目从建表到最后一个页面调通我大概花了两周左右的时间每天抽空写一点。做完之后我又给它加了对账用的销售日报统计、Excel导出订单等功能。后来一直接受顾客真实点餐使用运行了几个月都没有出过严重问题。所以如果你正在学Java Web或者正在为做毕设选题目我建议你真的找一个这类业务从头到尾把“设计-实现-调试”走一遍。把登录校验、事务、库存这几个核心点吃透后面做任何管理系统你都不会觉得虚。