ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java餐饮管理系统源码实战:从分层架构到并发扣库存

Java餐饮管理系统源码实战:从分层架构到并发扣库存 简介这套Java餐饮管理系统源码面向Java初学者及需要快速搭建餐饮管理后台的开发者覆盖登录管理、前台服务、后台管理、销售统计、系统安全和人员管理六大模块。其中前台包含开台点菜、菜单查看、餐桌状态查询与结账操作后台支持菜谱增删改、餐桌与订单管理销售统计提供日营业额和利润核算权限体系区分超级管理员与普通管理员便于理解角色权限设计。资源共24个文件包含22个Java源文件、1个Markdown说明文档和1个可运行的Jar包压缩包仅955KB结构紧凑适合直接导入IDE阅读或运行调试。已有527人学习下载对于正在做课程设计或毕业设计的读者可作为功能拆分、数据库表设计和权限控制方面的参考实现。1. java餐饮管理系统源码到底在解决什么问题餐饮管理系统源码是 Java 学习路径里最好上手的完整工程覆盖 CRUD、事务、状态机这一组典型业务复杂度又低于电商系统。解决的问题很直接收银员不用手动算账服务员能看到桌台占用情况老板能按天拉出营业额。对求职者来说它是把 Java SE、MySQL、Servlet 或 Spring Boot 串起来的一份完整图谱。说“源码”而不叫“成品”是因为你拿到的多半是一套可改的代码骨架桌台数量、菜品分类、打印模板都要自己调。常见路线有两条Servlet JSP MySQL 经典模式和 Spring Boot Vue 前后端分离模式。两种结构差别很大但订单、菜品、会员的核心业务规则相通。这篇文章按“先定边界再写代码最后验证”的顺序展开片段可以直接照抄重点放在容易写错、面试常被问的几处。2. 从单体代码到分层设计餐饮系统的技术栈与模块边界2.1 为什么多数源码工程选择分层而不是直接写 JDBC刚入门的人写餐饮系统容易把 JDBC 直接堆在 Servlet 里“点菜”这个动作从Connection到ResultSet全部写在一个方法里。几百行代码时问题不明显一旦把桌台状态和库存校验加进来一个方法里会出现十几个嵌套的try-catch报错时日志根本定位不准。最常见也最可靠的形态是经典三层架构controller 负责参数校验和页面或 JSON 返回service 放事务规则dao 只做 SQL 和结果集映射。代价是多写一些接口和实现类换来的是“扣库存”和“生成订单”可以放到同一个数据库事务里逻辑边界一眼能看到。2.2 餐饮系统区别于普通 CRUD 的三个核心动作2.2.1 用有限状态机管理桌台状态桌台不是一个简单的status字段而是一个状态迁移过程空闲、已入座、待结账三个状态不能任意跳转比如空闲桌不能直接变成待结账。用if堆状态越到后面越难维护我一般会在源码里定义一个枚举把允许的迁移路径集中管理。这样点菜、换桌、并桌这些操作里的状态判断就从多个布尔值的关系计算简化成一次枚举比对后续加“清洁中”之类的扩展状态只改迁移规则即可。2.2.2 订单头与订单明细的金额要分开算一桌客人可能加菜三次、退菜一次最后还有会员折扣。订单头的total_amount和订单明细行的amount如果只是简单相加退菜环节就会出问题退菜时如果把明细行删除营业额统计会丢失历史依据但订单头的金额又不能直接用当前时间点的价格重算。所以明细行要保留原价和数量退菜用负数行或单独标记结账时在事务里按规则重新聚合原始金额再与订单头对账。常见的误用是只在内存里修改明细集合不重新写库服务一重启账就对不上了。2.2.3 库存扣减的时机比算法重要扣库存必须发生在“下单成功”那一刻不能等到结账。高峰期一桌点三次菜如果每次点菜前都去数据库里重新查一遍现有库存就会出现同一份食材被多人同时核销的情况。可靠的做法是用乐观锁控制菜品表里加version字段扣减 SQL 语句里带where stock num and version ?更新成功的行数为 1 才算真正扣减成功。这个做法在并发点最后一份食材时只有一个请求能成功另一个会明确收到“库存不足”比把库存扣成负数再回滚更直观。2.3 MySQL 8、JDK 11 与内嵌容器的基础选型组件推荐选型原因JDKJDK 11 或 17长期支持版本源码不依赖旧私有包数据库MySQL 8连接驱动更成熟utf8mb4 支持稳定部署容器内嵌 Tomcat 或 Undertow本地调试链路短无需额外安装服务2.3.1 建库脚本的字符集和事务隔离级别数据库相关源码里第一处容易出错的是字符集菜品名带生僻字或图标时由于默认字符集不统一会出现乱码。建库时直接固定成 utf8mb4 最省事。事务隔离级别用READ COMMITTED基本够用SERIALIZABLE会让并发下单时锁竞争明显变重实在不放心就保留数据库默认的REPEATABLE READ但是要清楚多表擦除时产生的间隙锁可能让“换桌”操作互相等待。CREATE DATABASE IF NOT EXISTS restaurant DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;这里把字符集和排序规则固定下来是为了让后续连接的 URL 与表结构完全一致避免中文菜品名出现问号。排序规则选utf8mb4_unicode_ci是因为它按 Unicode 标准排序菜品以中文拼音排序时也能用ORDER BY name直接生效。2.3.2 JDK 版本与 JVM 内存设定直接固定到 JDK 11 或 17源码里不要出现sun.misc这类老私有包。JVM 堆内存建议-Xms256m -Xmx512m餐饮系统对象生命周期短真正的并发压力集中在数据库连接上堆开得太大反而让年轻代 GC 周期变长。2.3.3 容器用内嵌 Tomcat 或 Undertow调试源码阶段用 Spring Boot 内嵌的 Tomcat 或者 Undertow 都行链路越短越容易定位问题没必要在本地再套 Nginx。上线时再把静态资源和反向代理交给 Nginx应用源码里不用为此写任何额外分发逻辑。3. 从建表到业务逻辑核心源码的落地写法3.1 表结构设计与索引的关键决策3.1.1 五张核心表与一张建表脚本餐饮系统里最常见的表是桌台表、菜品表、订单头、订单明细和会员表库存可以先放在菜品表里也可以单独拆库存表。下面是一个可以直接抄的建表脚本覆盖订单头和菜品两张表的主要字段CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 0, -- 乐观锁版本号 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, table_id INT NOT NULL, member_id INT DEFAULT NULL, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_table_status (table_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;version字段对应上一章的乐观锁status存数字而不是字符串因为状态枚举定义在 Java 源码里翻译发生在业务层而不是 SQL 里。idx_table_status联合索引的建立依据是“查某个桌台有没有未结账单”是最高频查询单独在table_id上建索引会让最后的status过滤变成回表操作。3.1.2 为什么业务时间统一用 DATETIME如果订单创建时间用timestamp换到跨时区的云数据库时凌晨的订单日期可能漂移一天报表按天聚合就会出错。所以业务时间字段固定用DATETIME时区转换交给展示层。源码里不要依赖数据库服务器时区做金额或日期的运算。3.2 状态机枚举与事务边界加菜这个动作的完整实现3.2.1 桌台状态迁移的枚举定义把状态迁移写成枚举是餐饮源码里值得保留的做法public enum TableStatus { FREE(0) { Override public boolean canTransferTo(int target) { return target OCCUPIED.status; // 空闲只能转已入座 } }, OCCUPIED(1) { Override public boolean canTransferTo(int target) { return target FREE.status || target BILLING.status; // 已入座才能结账 } }, BILLING(2) { Override public boolean canTransferTo(int target) { return target FREE.status || target OCCUPIED.status; // 结账后可翻台或继续用餐 } }; public final int status; TableStatus(int status) { this.status status; } public abstract boolean canTransferTo(int target); }canTransferTo是抽象方法每个枚举常量自己实现迁移规则。这样换桌操作走OCCUPIED - FREE - OCCUPIED而FREE - BILLING会直接抛异常。如果不用枚举而用if (status 0)家族判断后期加“清洁中”这个状态时所有流程都要重新检查一遍枚举则只需要在迁移规则里增加一行。3.2.2 事务放 service锁的粒度放订单头“加菜”涉及订单头金额、订单明细、菜品库存三处更新事务必须包住整个业务方法而不能在 DAO 里逐个提交。下面是一个可以抄的 service 方法Transactional(rollbackFor Exception.class) // 扣库存与插入明细必须在同一事务 public Long addDishToOrder(OrderAddDTO dto) { OrderHeader order orderDao.lockById(dto.getOrderId()); // SELECT ... FOR UPDATE if (order.getStatus() ! OrderStatus.PLACED.status) { throw new BizException(订单已出厨不能再加菜); } Dish dish dishDao.selectByCode(dto.getDishCode()); int updated dishDao.reduceStockByOptimisticLock(dish.getId(), dto.getQty(), dish.getVersion()); if (updated 0) { throw new BizException(菜品库存不足或已被占用); } orderItemDao.insert(OrderItem.create(dto, dish.getPrice())); orderDao.recalcAmount(order.getId()); return order.getId(); }lockById的 SQL 是SELECT ... FOR UPDATE作用是把订单头行锁住避免两个收银员同时操作同一单时重算金额出错。注意FOR UPDATE必须在同一个事务里生效所以 service 方法上的Transactional不能省如果 DAO 方法自己开了 auto-commit锁就失去了意义。reduceStockByOptimisticLock里where stock ?和version ?两个条件缺一不可更新行数为 0 时直接抛业务异常而不是继续插入明细再靠数据库回滚。3.3 BigDecimal 与折扣计算钱不能算丢一分3.3.1 金额类型统一Java 实体里金额用BigDecimal数据库用DECIMAL(10,2)任何 double 运算都会在打印账单时留下精度隐患。折扣字段不要在明细行里修改原价订单头统一记录discount_amount报表算营业额时用明细原价算实收时再乘以折扣比例。3.3.2 积分流水要可追溯会员积分不要只在一个member.points字段上累加退菜和积分清零时没有依据。常见做法是单独建积分流水表字段带source_order_id和expire_at查询积分时对流水做SUM。读会员总积分比直接查字段慢但换来的可追溯性值得。4. 让源码跑起来接口对接、参数校验与框架整合的坑4.1 Servlet JSP 和 Spring Boot 怎么选如果源码是拿来准备面试的建议先用 Servlet JSP 把一个完整的点菜流程跑通理解HttpServlet的请求生命周期再看 Spring Boot 的DispatcherServlet封装。如果目标是快速给小店做后台直接选 Spring Boot MyBatis 更省事。MyBatis-Plus 的LambdaQueryWrapper写起来快但多表关联时生成的 SQL 不带FORCE INDEX在订单明细这种大表上统计信息一旦出现偏差优化器可能选错索引所以核心聚合查询还是手写 SQL 更可控。4.2 下单接口的入参设计与重复提交防护4.2.1 接口参数表下单接口常见的参数如下参数类型说明是否必填tableIdInteger桌台 ID创建订单与换桌时使用是memberIdInteger会员 ID非会员传 null否itemsList菜品明细包含 dishCode、qty是requestIdString幂等键前端生成防重复提交是operatorIdInteger操作员 ID理论取自登录会话而非请求体是requestId是餐饮源码里最容易漏的参数。页面在网关层没有做防抖时用户双击点菜会连续发两个完全相同的请求没有幂等键就会生成两张相同订单库存也扣两次。常见做法是在订单表上建唯一索引后端先尝试插入请求流水插入失败直接拒绝。operatorId千万不要信任前端传值否则伪造接口就能用全店最高权限的操作员下单。4.2.2 Controller 层校验与 service 层业务规则的拆分PostMapping(/order/create) public RLong createOrder(RequestBody Valid OrderCreateRequest req) { if (req.getItems() null || req.getItems().isEmpty()) { return R.fail(至少选择一个菜品); } if (req.getRequestId() null || req.getRequestId().length() 8) { return R.fail(缺少幂等键); } return R.ok(orderService.create(OrderCreateCmd.from(req))); }Valid配合 DTO 内的NotNull、Size(min 1)完成字段级校验。Controller 里的两个手动判断属于业务性参数检查与注解字段校验不冲突一个处理“字段没填”一个处理“请求在当前业务下没有意义”。分开后接口报错可以根据异常类型快速区分客户端问题还是服务端状态问题。4.3 菜品图片上传目录不能放在工程里4.3.1 用 WebMvcConfigurer 映射本地磁盘目录图片不要存到resources/static下jar 包内的文件在重启后会丢失或被只读限制挡住。常见做法是上传到磁盘绝对路径再用虚拟路径映射出去Configuration public class WebConfig implements WebMvcConfigurer { Value(${app.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceLocations(file: uploadDir); // 必须是 file: 前缀 } }注意uploadDir的值必须以/结尾file:前缀不能省略否则 Spring 会把它当成 classpath 路径。上传路径在application.yml里配成app.upload-dir: /data/restaurant/images/即可。4.3.2 文件名重命名与后缀白名单接收上传文件时不能用用户传的文件名直接落盘。一方面有路径穿越风险另一方面中文文件名经过 Nginx 后会变成 URL 编码访问时容易 404。常见处理是取后缀用 UUID 重命名String ext FilenameUtils.getExtension(originalFilename); if (!ALLOWED_EXTS.contains(ext)) { throw new BizException(只允许图片文件); } String storedName UUID.randomUUID().toString().replace(-, ) . ext; File dest new File(baseDir, storedName); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest);FilenameUtils来自 Apache Commons IO只做后缀提取不负责判断文件真实内容。生产环境如果担心伪装后缀可以加上ImageIO.read再做一次解码校验能读成BufferedImage才允许落盘。4.4 菜品列表缓存更新与 JMeter 压测验证菜品列表是典型读多写少加一层本地缓存能明显降 DB 压力。后台改完价格后只需要按菜品 ID 清除对应缓存不要清空全表缓存否则高峰期第一个请求会全部打到数据库。用 JMeter 压测下单接口时重点关注两个指标单请求响应时间和“库存不足”的报错比例。如果并发 20 个请求里频繁出现乐观锁更新失败先检查是不是压测脚本复用了相同的 requestId幂等键把并发请求误判成了重复请求。5. 点菜链路验证一条 SQL 和一次 curl 找出源码隐患5.1 用对账 SQL 检查订单金额一致性订单头和明细分离后最容易出现的问题是金额对不上典型场景是退菜时只改了一个表。我一般会在测试环境先跑这条 SQLSELECT o.id, o.table_id, SUM(i.amount) AS detail_total, o.discount_amount, o.total_amount, (SUM(i.amount) - o.total_amount) AS diff FROM orders o LEFT JOIN order_item i ON o.id i.order_id WHERE o.created_at 2024-01-01 GROUP BY o.id HAVING diff ! 0;有折扣时diff恰好等于discount_amount是正常的如果出现与折扣无关的零散差值基本可以断定代码里某个页面用的订单总额来源和结账页不一致。注意HAVING里引用 select 别名在 MySQL 8 中可用遇到旧版本报错就改成在查询外面包一层子查询。5.2 用固定 requestId 做幂等验证用 curl 模拟双击场景是验证幂等键是否生效的最快方法curl -X POST http://localhost:8080/order/create \ -H Content-Type: application/json \ -d {tableId:1,requestId:req-20250101-0001,items:[{dishCode:D001,qty:2}]}同一个requestId连发两次第一次返回订单号第二次应当返回自定义的“重复请求”业务错误。若第二次又生成了新订单就检查订单表上的唯一索引是否真的建上了很多时候代码里写了saveOrUpdate导致唯一约束被跳过。5.3 验证失败时的一个排查位置如果扣库存一直报“库存不足”先看控制台打印的 SQL确认事务提交方式不是autocommit。SELECT ... FOR UPDATE和乐观锁更新两者如果分别跑在不同连接上后一个请求永远看不到前一个请求未提交的变更表现就跟库存不足一样。排查时把数据源的连接池最大连接数调小配合慢查询日志查看同一秒内是否出现两个不同的 connection id就能确认是不是连接复用出了问题。本文还有配套的精品资源点击获取
返回列表