尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java餐馆管理系统开发实战:表结构、事务与并发处理

Java餐馆管理系统开发实战:表结构、事务与并发处理 做餐馆管理系统这个题目最初其实是课程设计的要求。题目看起来很常规——基于Java的餐馆管理系统的设计与实现甚至有点老套网上随便一搜各种版本的源码一大堆不少还挂着关注可白嫖源码的引流标题。但能搜到和能跑通是两回事能跑通和能看懂又是另外两回事。真正动手写的时候我发现这个题目涉及的模块比我预想的多很多桌台状态要管菜品库存要管订单从下单到结账的每一步都得有记录还要考虑服务员、后厨、老板不同角色的权限。这篇文章就把我做这套系统的完整思路和踩坑经历写出来包括表结构怎么设计、关键接口怎么写、并发和金额这类细节怎么处理给正在做同类题目、或者想自己开发一套轻量餐馆管理系统的朋友做参考。项目技术栈选的是 Java Spring Boot MyBatis-Plus MySQL这是目前同类系统中最常见、资料最好找的一套组合。如果你手里的是 Servlet JSP 或者 SSM 框架核心思路同样能复用差别主要在编码习惯上。下面讲的每一条都是我在实际开发、测试、答辩演示过程中真实遇到过的问题不是从哪篇论文里抄来的理论。1. 为什么我依然推荐Java来搭餐馆管理系统1.1 这个项目真正考察的是什么先下一个判断餐馆管理系统这个题目难点不在算法而在业务逻辑的完整性和状态处理的严谨性。它本质上是一个多角色参与、有状态流转、带资金计算的业务系统这类系统在真实软件公司里是最常见的类型。无论是课程答辩还是面试展示人家第一眼看的不是你有没有用上 Redis、MQ 这些中间件而是订单从创建到结账的链路是否走得通桌台状态是否始终一致金额计算是否经得起追问。举个例子结账这一步看起来简单实际上牵扯到桌台状态更新、订单状态变更、订单明细快照、支付方式记录甚至还有可能产生优惠分摊。如果你只写了一句orderMapper.updateById(order)那答辩时老师问你这桌客人还没结账就把桌子改成空闲了怎么办你很难给出合理回答。所以我在动手写代码前先花了整整两天梳理业务流程把每个动作涉及的状态变化画清楚这个投入后面证明是非常值得的。1.2 Java生态对比其他语言的取舍有人觉得用 Python Flask 或者 PHP 写会更轻快我承认在做出一个能点餐的页面这个层面那些语言确实更快。但我对比过之后还是选了 Java原因很实际Spring Boot 的事务管理、分层约束和生态成熟度对这类多模块业务系统太重要了。单拿事务来说一次点餐操作至少要插入订单主表、批量插入订单明细、更新桌台状态、扣减库存这四件事必须要么全成功要么全失败。Spring 的Transactional注解一行就能搞定而在 Flask 里你得自己管理 session 提交和回滚一旦某个环节忘记 rollback脏数据写进去就非常难排查。对比项Spring Boot JavaFlaskPythonPHP 原生事务控制Transactional声明式便捷需手动 commit/rollback需手动处理或依赖框架项目分层约定清晰适合多人协作自由度高小项目尚可大了难约束代码风格差异大资料与问题搜索极多几乎任何报错都能搜到中等多但质量参差部署与演示打包 jar 即可运行需配置 wsgi 服务需搭配 Apache/Nginx表格里的前两行我很看重。餐馆管理系统看着小但一旦加了会员充值、折扣活动、桌台预订这些功能代码量会迅速膨胀。Java 的分层约束能帮你把 Controller、Service、Mapper 各归其位后续加功能的时候不需要推翻重来。注意选择 Java 不代表背了一堆框架就能交差。Spring Boot 只是武器业务设计才是核心下面这部分才是这套系统的重头戏。2. 餐馆管理系统需求拆解别被点餐两个字带偏2.1 核心业务域到底有哪些很多初学者拿到题目后第一反应是我要做一个点餐系统。然后数据库里建两张表一张menu装菜品一张order装订单做完一个能选菜、能下单的 demo 就认为完工了。如果只是汇报页面效果这确实能应付但作为一个管理系统它远远不够。我在系统里划分了七个业务域每个业务域下再拆具体功能桌台管理桌号、座位数、状态空闲/占用/已预订/待清理支持开台、换桌、并桌菜品管理菜品分类、价格、估清状态今日售罄、上下架、图片订单管理开台点餐、加菜、退菜、整单结账、部分结账AA会员管理开卡、充值、余额消费、积分累计支付管理现金、扫码、会员余额记录支付流水库存管理原料库存或菜品每日备货量低于阈值提醒报表统计日营收、菜品销量排行、桌台翻台率这套划分不是拍脑袋来的而是我参考了市面上几套收银系统的功能清单后简化出来的。课程设计虽然不用做那么重但哪怕你只实现其中四个域系统的完整度就已经超过大多数同类项目。2.2 角色权限与状态流转怎么设计系统不是一个人用的。服务员负责开台下单后厨负责看单做菜店长或老板负责菜品维护和查看报表管理员管理账号。如果所有角色共用一套页面和接口轻则使用混乱重则出现服务员把菜品价格改了的低级事故。我的方案是在用户表加一个role字段取值ADMIN、WAITER、CHEF、BOSS然后写一个简单的拦截器在请求进入 Controller 前校验角色权限。课程设计阶段没必要引入 Spring Security 这样的重型安全框架一个 HandlerInterceptor 加一个自定义注解就够用。状态流转是另一个容易翻车的地方。我最初把订单状态和桌台状态混在一起用一个大字符串表示比如已下单制作中已上齐待结账已完成后来发现根本维护不了。正确做法是让桌台状态和订单状态各管各的状态字段可选值变更触发方table.statusFREE / OCCUPIED / RESERVED / DIRTY开台、结账、清理order.statusCREATED / ORDERED / SERVING / SETTLED / CANCELED下单、上菜、结账以一次完整就餐为例服务员开台后桌台从FREE变OCCUPIED点餐后生成CREATED状态订单确认下单后订单变ORDERED后厨出菜、服务员上齐菜品后订单变SERVING结账完成后订单变SETTLED桌台变DIRTY保洁清理后再把桌台改回FREE。这样一个链路理清楚代码里就是一次次简单的状态更新不会出现逻辑死角。3. 数据库与后端设计表结构先行代码只是搬运工3.1 核心表结构详解我见过不少人一上来就写代码写到一半发现字段不够用再回头改表改得数据库和实体类对不上最后越写越乱。所以我的习惯是先用 SQL 把核心表建出来确定所有字段含义再动手写 Java 代码。核心表一共五张user、table_info、dish、orders、order_item。下面给出简化版的建表语句你可以直接拿去改。CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号对外展示用, table_id bigint(20) NOT NULL COMMENT 桌台ID, status varchar(20) NOT NULL DEFAULT CREATED, total_amount decimal(10,2) NOT NULL DEFAULT 0.00, discount_amount decimal(10,2) NOT NULL DEFAULT 0.00, pay_amount decimal(10,2) NOT NULL DEFAULT 0.00, pay_type varchar(10) DEFAULT NULL COMMENT CASH/SCAN/BALANCE, user_id bigint(20) DEFAULT NULL COMMENT 操作服务员ID, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, settle_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, dish_id bigint(20) NOT NULL, dish_name varchar(100) NOT NULL COMMENT 菜品快照名称, price decimal(10,2) NOT NULL COMMENT 菜品下单时单价快照, quantity int(11) NOT NULL, subtotal decimal(10,2) NOT NULL COMMENT 小计, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个容易被忽略的设计点order_no不能用自增 id 直接对外展示。自增 id 容易暴露一天的订单量而且多表联调时容易混淆。我用的方案是yyyyMMddHHmmss 三位随机数简单够用。如果以后要支撑高并发可以换成雪花算法。order_item里存了dish_name和price快照。这是必须的因为菜品表里的价格可能会改但历史订单上记录的应该是客人点单那一刻的价格不能跟着最新菜品价格变化否则报表统计和账单追溯都会乱套。3.2 点餐、加菜、结账三个关键接口的实现表结构定完之后核心接口的实现方式就清晰了。我拿用户下单这个接口举例完整演示一下 Service 层的处理逻辑。Override Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 校验桌台状态 TableInfo table tableMapper.selectById(dto.getTableId()); if (table null || !TableStatus.FREE.equals(table.getStatus())) { throw new BusinessException(当前桌台不可用); } // 2. 校验菜品并计算金额 BigDecimal total BigDecimal.ZERO; ListOrderItem itemList new ArrayList(); for (OrderItemDTO itemDTO : dto.getItems()) { Dish dish dishMapper.selectById(itemDTO.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BusinessException(菜品不存在或已下架); } BigDecimal subtotal dish.getPrice() .multiply(BigDecimal.valueOf(itemDTO.getQuantity())); total total.add(subtotal); OrderItem item new OrderItem(); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setPrice(dish.getPrice()); item.setQuantity(itemDTO.getQuantity()); item.setSubtotal(subtotal); itemList.add(item); } // 3. 生成订单号保存订单主表 Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setTableId(dto.getTableId()); order.setStatus(OrderStatus.CREATED); order.setTotalAmount(total); order.setPayAmount(total); order.setUserId(LoginUser.getUserId()); orderMapper.insert(order); // 4. 保存订单明细 for (OrderItem item : itemList) { item.setOrderId(order.getId()); } orderItemMapper.insertBatch(itemList); // 5. 更新桌台状态为占用 tableMapper.updateStatus(dto.getTableId(), TableStatus.FREE, TableStatus.OCCUPIED); return order.getId(); }这里的每一个步骤都是有讲究的方法加了Transactional(rollbackFor Exception.class)保证第 3 到第 5 步任何一个环节失败整个订单都不会留下半截数据。桌台状态更新用了一个带条件更新的 SQLUPDATE table_info SET status OCCUPIED WHERE id ? AND status FREE。这是为了防止两个服务员同时对同一张桌开台靠数据库层面的条件更新把并发问题挡在门外。菜品价格在存入订单明细时做了一次快照之后订单金额与菜品表价格变动彻底解耦。加菜接口的逻辑和下单类似区别在于不是新建订单而是在已有订单上追加明细然后重新计算总金额和应付金额。结账接口稍微复杂一点它要把订单状态从SERVING改成SETTLED同时把桌台改成DIRTY并且写入支付流水。如果用了会员余额支付还需要做余额扣减这部分同样要在一个事务里完成。3.3 事务与并发库存超卖是怎么发生的在 3.2 里我提到了带条件更新的 SQL这个思路在后端开发里特别重要。第一次写完版本后我做过一次并发测试——用两个线程同时给同一道菜下单结果库存直接变成了负数。问题出在老写法上// 错误示例先查再改并发下一定会出问题 Dish dish dishMapper.selectById(dishId); if (dish.getStock() quantity) { throw new BusinessException(库存不足); } dish.setStock(dish.getStock() - quantity); dishMapper.updateById(dish);两个请求同时执行第一步查库存时都读到了stock3而quantity2都判断库存充足然后各自执行了减 2真实库存本来应该变 -1但库里的值却能变成实际剩下的那个数。这里的关键是检查与更新之间存在时间差而Transactional并不能解决这种并发问题。正确的做法是让数据库在更新时做原子判断// 正确示例数据库行锁 条件更新原子扣减 int rows dishMapper.deductStock(dishId, quantity); if (rows 0) { throw new BusinessException(库存不足); }-- 对应的 SQL UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}UPDATE执行时会对这一行加锁第二个请求必须等第一个提交或回滚后才能继续而stock #{quantity}这个条件保证了扣减后不会出现负数。这个方法推荐直接背下来在很多库存类系统里都是通用解法。4. 实测中踩过的坑金额、库存、结账这三件事4.1 金额精度问题Double为什么不能用如果你在网上找过餐馆管理系统的源码大概率见过这种写法double total 0.0; for (Dish dish : dishList) { total dish.getPrice() * quantity; }看起来很自然但跑一次就会发现0.1 0.2的结果不是 0.3而是 0.30000000000000004。你不信的话用 Java 写一行System.out.println(0.1 0.2);验证一下。这是因为二进制浮点数无法精确表示大部分十进制小数而餐饮系统的金额每一分钱都要对得上所以坚决不能用double。我后来把所有金额字段全部改成BigDecimal数据库字段改成decimal(10,2)。用 BigDecimal 还有一个细节要注意构造时不能用new BigDecimal(0.1)因为 0.1 的二进制浮点值本身就是不精确的这样构造出来的对象依然带着一长串小数。正确做法有两种// 推荐方式一字符串构造 BigDecimal price new BigDecimal(19.90); // 推荐方式二valueOf 转换本质是调用了 Double.toString BigDecimal total BigDecimal.valueOf(19.90);在计算小计和总价时统一用BigDecimal的add、multiply方法最后setScale(2, RoundingMode.HALF_UP)保留两位小数金额账目才真正干净。4.2 库存扣减从一次线上实测看完整排查链路库存扣减的问题我虽然知道理论但真正理解还是在一次联调测试时。当时系统已经接入了前端页面我让测试员连续快速点击下单按钮结果一份菜卖出了 8 份而库存只设置了 5 份。排查过程大概花了一个下午。第一步我先看日志发现没有报错说明逻辑层认为操作全部成功了。第二步我打开数据库看库存变成了 -3确定是超卖。第三步我思考为什么Transactional没兜住把问题定位在先查后改这个模式上因为两个事务可以同时读到相同的旧库存值。第四步我上网搜同类型问题发现了条件更新这个方案改完后再次用并发测试脚本验证库存始终正确。这个排查链路值得记录不是因为它复杂而是因为很多人遇到并发问题第一反应是加锁或者加队列。但在这个场景里最简单可靠的方案其实就是一句带条件的 SQL充分理解数据库的行锁机制比引入任何中间件都更有效。4.3 结账拆分与并桌的边界情况项目做到后期店长提了两个需求一桌客人要 AA 结账两桌熟人想并成一桌。一开始我有点犯难因为我的订单表和桌台表是一对一关系一个订单只能挂在一张桌下。后来我调整了模型订单主表保存了table_id和total_amount同时增加了一个split_amount字段用来记录当前这笔结账支付了多少钱。AA 结账时同一张桌台可以生成多笔结算单但每笔结算单都指向同一个订单号金额累加不超过订单总金额即可。并桌呢最简单的方案是只允许并桌后重新点餐旧桌结清、新桌合并这样逻辑上最简单也不容易出账目问题。如果你要把并桌做得更细比如要把已下的菜一起转过去那就需要在订单明细上增加一个来源桌台字段工作量会大不少。我建议课程设计阶段做清楚 AA 拆分并桌可以做成先结账后合并开台演示效果已经完全够用。提示涉及钱的边界情况优先保证账目能对上。宁可功能简单也不能出现订单金额对不上明细的情况。5. 从课程设计到真实项目我总结的几条实用经验5.1 分层与命名的隐性要求新手写代码最常见的毛病是一个 Java 文件里塞了所有逻辑。我第一版就是这样Controller 里直接写 SQL 操作写的时候很爽后面加一个会员功能时差点崩溃。后来我严格按照 Controller - Service - Mapper 三层来组织职责就清晰了。推荐包结构如下com.example.restaurant ├── controller # 接收前端请求参数校验 ├── service # 业务逻辑事务控制 ├── mapper # 数据库操作接口 ├── entity # 数据库实体类 ├── dto # 前端交互参数对象 ├── common # 通用返回结果、异常处理 └── config # 配置类拦截器注册等命名方面也要统一。比如订单领域我全部使用Orders表示订单主表、OrderItem表示明细OrderController、OrderService、OrderMapper一致对应。有人喜欢叫orderInfo、order_detail_list这类名字结果代码里搜一下order出来七八个类自己都分不清谁是谁。小项目可以不拘小节但如果你打算把项目放进简历整洁的分层和命名本身就是加分项。5.2 如何让源码真正可复现关注可白嫖源码的标题大家应该都见过但真正下载过源码的人都知道跑不起来才是常态。作为一个分享源码的人我后来做了一件事认真写 README。我的 README 包含这几个部分环境要求JDK 版本、MySQL 版本、Maven 版本启动步骤建库 - 导入 sql 脚本 - 修改 application.yml 里的数据库账号密码 - 启动后端 - 启动前端内置账号例如 admin/admin123、waiter/123456常见问题比如端口被占用、数据库连接失败怎么处理。别小看这几段文字因为大部分学生拿到源码后第一件事就是照着 README 操作能不能跑通直接影响他们对整个项目的评价。而且你会发现写 README 的过程中还能倒逼自己把代码里所有外部依赖比如图片路径、文件上传路径都做成可配置的项目本身的健壮性也会提升。5.3 后续扩展从单店到多门店外卖的思路如果做完这套基础版之后还想继续深入我建议按下面这个顺序扩展加 Redis菜品列表和桌台状态是热点数据用 Redis 做缓存能显著提升查询速度。这一步需要处理缓存和数据库的一致性问题。加消息队列用户下单后把通知后厨这个动作放到队列里异步执行避免高峰时段点餐接口响应变慢。小项目用 RabbitMQ 就行重点是理解异步解耦的思路。加多门店维度在所有业务表上加一个store_id字段所有查询都带上这个条件。这个改动越早做后面扩展越轻松。对接支付 API扫码支付回调必须处理幂等问题不然支付平台重复通知会导致订单状态被覆盖。每一步单独拎出来都能写一篇长文但核心基础依然是前面讲的表结构、事务和状态管理。地基打牢了上面盖几层楼都不慌。最后说点个人体会。餐馆管理系统这类题目之所以被反复用来做课程设计和毕业设计就是因为它麻雀虽小五脏俱全从用户权限、业务流转到资金计算覆盖了业务系统最常见的所有要素。我做完这套系统后最大的收获不是学会了 Spring Boot 的某个注解而是养成了动手前先理清状态和流程的习惯。后来工作里遇到更复杂的订单系统回头看的还是这套基本功。希望你做完这个项目也能有同样的感受。
返回列表