ARTICLE DETAIL

资讯详情

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

Java咖啡厅管理系统实战:从建表到订单库存一致性实现

Java咖啡厅管理系统实战:从建表到订单库存一致性实现 简介JavaWeb项目开发中一套可运行的管理系统远不止增删改查核心在于表结构设计与事务边界。以课设常见的咖啡厅管理系统为例菜单表需做上下架状态订单明细要存商品快照点单扣库存与撤销回滚必须放入同一事务。基于Spring Boot 2.7与MyBatis-Plus 3.5可快速搭建单体后端通过原子SQL避免超卖借助Transactional保证订单、库存、积分的一致性。此类设计同样适用于餐饮、零售等小规模交易系统理解订单主链路、表职责划分与并发扣减原理能够显著减少数据不一致事故。本文结合建表SQL、Service实现与并发验证脚本给出从零落地咖啡厅管理系统的完整方案。1. 一份“基于java的咖啡厅管理系统”文档为什么不能直接照着做毕业设计文档堆里。“基于java的咖啡厅管理系统设计与实现”这类题目一年能见几十次但真正动手跑过一遍的人都知道卡脖子的大多不是Java而是表结构和事务边界。菜单表没做上下架状态、订单明细不存商品快照、点单扣库存和撤销回滚没放进同一个事务里这些问题在文档的用例图里根本看不出来。这篇笔记不打算复述那份设计文档而是按我平时接手这类系统的方式从建表、搭后端到订单撤销给出一套最小可运行的方案。适合正在做课程设计、准备答辩的同学也适合想仿写一个Java后端项目攒经验的从业者。看完你至少能回答咖啡厅系统最核心的表有哪几张点单和撤销的坑到底在哪。2. 系统拆解先定边界再动手建表管理系统的文档往往一上来就是一大摞用例图、流程图到了数据库设计又铺出几十张表。咖啡厅管理系统要是照着全部做一个月不一定做得完。我接到这类需求先做减法只保留“顾客坐下到结账离开”的主链路以及这条主链路依赖的会员和库存。主链路闭环了系统才能叫“咖啡厅管理系统”而不是“咖啡厅进销存人事系统”。2.1 咖啡厅管理的五个模块为什么排班和进货不该进核心表判断一张ER图值不值得做就看它能不能支撑核心业务闭环。咖啡厅的核心路径是顾客看菜单 → 点单 → 后厨出餐 → 结账 → 记会员积分。为了跑通这条链路我一般只保留五个模块菜单模块菜品分类、菜品上下架、价格。桌台模块桌号、桌台状态、开台与撤台。订单模块订单主表、订单明细。会员模块会员档案、积分账户、积分流水。库存模块菜品可售库存、库存变更记录。排班、进货、供应商、财务审计这些模块你在文档里写得很漂亮但一旦引入表之间的关系会从一条线变成一张网。比如排班要关联员工和班次提成要关联订单和排班进货要关联供应商、采购单和入库单。对课程设计或者一个小型门店来说这些功能半个月做完都算快而且对“点单—结账”没有任何直接增益。我见过太多人把时间耗在员工排班表上最后订单模块草草了事答辩时被问两句就卡壳。正确做法是把这些需求标成“二期”正文不建表接口不预留先守住主链路。模块边界定了之后每张表的职责也清晰了菜品表管“卖什么”桌台表管“在哪卖”订单表管“卖给了谁、多少钱”订单明细表管“到底卖了哪几样”库存表管“还能不能卖”会员表管“怎么让人回头再买”。职责不重叠后续写代码时Service层就不会乱成一锅粥。2.2 实体类与建表SQLMyBatis-Plus能不能根据实体类生成先说结论MyBatis-Plus的代码生成器解决的是“表已存在反向生成实体类和Mapper”不是“实体类已存在正向生成建表SQL”。很多人搜“mybatisplus根据java实体类生成创建表的sql语句”以为加个注解就能自动建表实际官方并不支持。网上有第三方反射工具能根据实体类拼出CREATE TABLE但我不会拿来建生产表因为自动生成的DDL不处理索引设计、字段注释和默认值。我常见做法是手写SQL再让实体类跟它严格对应出问题照DDL排查也更快。以菜单表为例实体类这样写TableName(dish) public class Dish { TableId(type IdType.AUTO) private Long id; private String name; private BigDecimal price; private Integer stock; private Integer status; }对应建表语句CREATE TABLE dish ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, name varchar(64) NOT NULL COMMENT 菜品名称, price decimal(10,2) NOT NULL COMMENT 售价, stock int(11) NOT NULL DEFAULT 0 COMMENT 可售库存, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品;这段对应关系很关键。开启驼峰映射后数据库的name、price、stock、status会自动映射到实体类同名驼峰字段不用每个字段都写TableField。id上的TableId(type IdType.AUTO)告诉MyBatis-Plus插入数据后把数据库自增主键回填到对象里后面创建订单明细时就能直接拿到orderId。status字段如果默认值是1查询菜单时记得过滤status1否则下架商品还能被点单。手写SQL时有三个经验一是字符集用utf8mb4别用utf8咖啡菜单里的“☕”“É”这类字符才能正常存二是金额字段用decimal(10,2)别用double对账时浮点数误差会让人怀疑人生三是状态字段用tinyint并带默认值不允许为NULL不然代码里到处要判空。2.3 订单表和订单明细表为什么明细要存商品快照订单主表保存一笔订单的头部信息哪个桌台、哪个会员、总金额、当前状态。订单明细保存这笔订单里点了哪些商品、当时单价多少、数量多少。建表SQL如下CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, desk_id bigint(20) DEFAULT NULL COMMENT 桌台id, member_id bigint(20) DEFAULT NULL COMMENT 会员id, total_amount decimal(10,2) NOT NULL COMMENT 订单总额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待结算 1已结算 2已撤销, create_time datetime NOT NULL, cancel_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, dish_id bigint(20) DEFAULT NULL, dish_name varchar(64) NOT NULL COMMENT 下单时菜品名称, price decimal(10,2) NOT NULL COMMENT 下单时售价, count int(11) NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细;明细表里必须存dish_name和price而不是只存dish_id。原因是咖啡厅调价很频繁今天的美式20元明天可能22元。如果明细只存dish_id统计月度营业额时系统会把历史订单全部按最新价格重算一遍财务报表当场翻车。存字段快照是订单系统的常识也是“设计与实现”文档里最该写清楚的内容。订单状态用0、1、2这样的数字不要用is_payed布尔值。布尔值只能表达“未付/已付”等你要支持“撤销”“退款中”时就得改表加字段数字状态则可以一直扩展。查询历史订单做统计时只要加status!2撤销订单就被排除掉了。建表时我也没有加外键约束。外键看起来严谨但在MyBatis-Plus批量操作、分页查询时外键会让删除顺序、锁竞争都变复杂实际维护成本高于收益。数据一致性靠Service层事务来控制DBA查问题也更方便。这一点很多教材不会提但是做项目时很实在。3. Spring Boot MyBatis-Plus 搭建后端让查询和分页少写一半代码表结构定完下一步就是搭后端骨架。咖啡厅管理系统不需要微服务也不需要复杂架构单体应用足够。Spring Boot加MyBatis-Plus是这类系统最常见的组合原因就一个简单到能把主要精力放在业务上。3.1 选型Java 8 Spring Boot 2.7 MyBatis-Plus 3.5我一般不会一上来就选最新的Spring Boot 3.x。Spring Boot 3强制JDK 17而很多课程设计、学校机房的环境还停留在JDK 8MyBatis-Plus适配JDK 17时也踩过不少版本坑。用Spring Boot 2.7.x配Java 8是最省环境成本的选择。pom里的核心依赖是这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version scoperuntime/scope /dependency /dependencies这里面版本号可以按你实际工程微调但大方向不要动Spring Boot 2.7.x配MyBatis-Plus 3.5.3.x是经过大量项目验证的稳定组合。MySQL驱动用8.0.33对应MySQL 8数据库没有问题。如果本地还是MySQL 5.7驱动8.0也可以兼容。有人喜欢在这里引入Lombok减少getter/setter代码能用但我做课程设计的代码会尽量少引依赖避免答辩时环境突然编译不过去。实体类getter/setter看着多可读性其实更好。3.2 application.yml 数据源配置utf8mb4 和 serverTimezone 一个都不能少配置文件是第一个容易翻车的地方。下面这份是我常用的最小配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cafe_db?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0url里的参数每一个都有用。useUnicode和characterEncodingutf8mb4保证中文和emoji字符写入数据库不乱码serverTimezoneAsia/Shanghai解决MySQL连接时报“The server time zone value”错误也避免时间差8小时allowPublicKeyRetrievaltrue是MySQL 8.0新版本连接时常见的坑不加可能直接连不上。map-underscore-to-camel-case开启后SQL里的create_time能自动映射成Java里的createTime不用手动写TableField。开发期log-impl打开SQL日志能直接看到MyBatis-Plus执行的SQL排错非常有用上线前再把它改成org.apache.ibatis.logging.nologging.NoLoggingImpl就行。逻辑删除配置需要留意我配置了logic-delete-field: deleted意思是对所有带deleted字段的表生效。所以建表时每张表都要加上deleted字段。好处是撤销订单、删除菜品这些操作不会真删数据统计报表还有后悔药。代价是后面查询时MyBatis-Plus会自动追加deleted0条件如果忘了给某张表加字段查询结果会神秘为空这个坑在第5章我会专门提到。3.3 分页插件与Controller注册一个Bean解决所有分页MyBatis-Plus分页不是开箱即用必须注册拦截器。我在项目里是这样写的Configuration MapperScan(com.cafe.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段代码做了两件事让Spring扫描com.cafe.mapper包下的所有Mapper接口不用每个Mapper都写Mapper注册MyBatis-Plus拦截器分页插件才会生效。注意这里必须用MybatisPlusInterceptor老项目的PaginationInterceptor在3.5.x里已经不推荐很多“分页不生效”的问题就出在这。Controller层我习惯保持薄薄一层分页接口长这样RestController RequestMapping(/dish) public class DishController { Resource private DishMapper dishMapper; GetMapping(/page) public RPageDish page(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size) { PageDish page dishMapper.selectPage( new Page(current, size), new LambdaQueryWrapperDish() .eq(Dish::getStatus, 1) .orderByDesc(Dish::getId)); return R.ok(page); } }R是我自定义的统一返回体里面放code、message和dataController只做参数接收和结果封装。分页对象直接塞进Page里前端拿到后能从records拿到当前页数据从total拿到总条数。LambdaQueryWrapper用方法引用替代字符串列名写错一个字母在编译期就能报错比QueryWrapper硬写字符串稳得多。4. 订单与库存的一致性点单扣库存撤销回库存咖啡厅管理系统里业务味最浓的不是增删改查而是点单时扣库存和撤销时回库存。这两个操作如果不放在同一个事务里库存和订单的对不上只是时间问题。这一章讲清楚我为什么选择“先扣库存再写订单”和“先置状态再补偿”。4.1 原子SQL扣库存不让超卖成为第一次上线事故点单的核心流程是校验菜品是否存在、判断库存够不够、扣减库存、写订单、写订单明细、如果是会员还要加积分。很多人的第一版代码是这样写的先select查询库存然后在Java里if判断最后执行update扣减。这个写法在并发量稍微上来一点就会超卖。两个请求同时读到库存1都认为可以卖都去执行update最后库存变成-1。修复方案是用一条原子SQL把“检查库存”和“扣减库存”合并Update(update dish set stock stock - #{count} where id #{id} and stock #{count}) int reduceStock(Param(id) Long id, Param(count) Integer count);这条SQL是扣库存的最佳实践。stock #{count}让数据库在更新前做检查影响行数为1说明扣减成功影响行数为0说明库存不足不用提前select。把判断和扣减放在数据库一侧天然避免了并发情况下读到的旧库存。对应的Service方法必须加事务Service RequiredArgsConstructor public class OrderService { private final DishMapper dishMapper; private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final MemberMapper memberMapper; Transactional(rollbackFor Exception.class) public Order createOrder(CreateOrderReq req) { int updated dishMapper.reduceStock(req.getDishId(), req.getCount()); if (updated 0) { throw new BizException(库存不足); } Dish dish dishMapper.selectById(req.getDishId()); Order order new Order(); order.setOrderNo(generateOrderNo()); order.setDeskId(req.getDeskId()); order.setTotalAmount(dish.getPrice().multiply(BigDecimal.valueOf(req.getCount()))); order.setStatus(0); orderMapper.insert(order); OrderItem item new OrderItem(); item.setOrderId(order.getId()); item.setDishId(dish.getId()); item.setDishName(dish.getName()); item.setPrice(dish.getPrice()); item.setCount(req.getCount()); orderItemMapper.insert(item); if (req.getMemberId() ! null) { memberMapper.addPoints(req.getMemberId(), req.getCount()); } return order; } }这里的Transactional(rollbackFor Exception.class)是关键。Spring默认只回滚RuntimeException如果业务代码抛了一个checked Exception库存扣了但订单没写事务照样提交这种翻车就是血泪经验。显式声明rollbackForException.class后任何异常都让整段操作回滚。为什么先扣库存再写订单因为扣库存是失败率最高的步骤先失败就能提前结束方法省掉后面创建订单对象和明细对象的时间。事务无论如何都会回滚所以“先扣库存后写订单”还是“先写订单后扣库存”在数据一致性上没有区别但前者的失败路径更短。4.2 撤销订单的补偿顺序先置状态再回库存最后扣积分撤销比创建更容易被人忽略。创建订单时所有操作都往同一个方向走撤销却要把库存加回来把积分减回去还得保证顾客不能对着一个已撤销的订单反复点击退款。我的撤销方法长这样Transactional(rollbackFor Exception.class) public void cancelOrder(Long orderId) { Order order orderMapper.selectById(orderId); if (order null || order.getStatus() ! 0) { throw new BizException(当前订单不可撤销); } orderMapper.updateStatus(orderId, 2); ListOrderItem items orderItemMapper.selectList( new LambdaQueryWrapperOrderItem() .eq(OrderItem::getOrderId, orderId)); for (OrderItem item : items) { dishMapper.addStock(item.getDishId(), item.getCount()); } if (order.getMemberId() ! null) { memberMapper.subPoints(order.getMemberId(), order.getPoints()); } }第一步先检查订单状态只有status0的待结算订单允许撤销。然后立刻把订单状态改成2再去回库存、扣积分。为什么要先改状态因为如果后改状态回库存阶段一旦抛异常事务会回滚状态仍是0顾客可以再次发起撤销造成同一笔订单重复回库存。先改状态后做补偿配合事务回滚系统不会出现“订单撤销了但库存没回来”的中间状态。订单撤销不要用delete操作。订单行和明细是销售统计、积分流水、财务对账的数据源物理删除了统计口径就变成了黑匣子。用状态字段标成2查询时过滤一下才是可持续的方案。这样的“状态机约束”虽然多写一个判断但换来了操作幂等。补偿顺序上我坚持“回库存前回滚”即先改状态、再回库存、最后扣积分。因为这个顺序中任何一步失败整个事务都会回滚到最初状态。而最终一致性靠的就是这个整体回滚能力。不要在这个方法里加try/catch一旦吞掉异常事务标记为rollback-only后面代码做什么都可能报“UnexpectedRollbackException”排查起来非常痛苦。4.3 积分流水表会员余额不要直接加减会员积分在咖啡厅里很常见但很多人实现时只在member表里存一个points字段加积分时update pointspoints5消费积分时update pointspoints-5。这样好处是查询快坏处是积分对不上时根本查不出是哪笔订单造成的。我习惯加一张member_points_log流水表每次积分变更都写一条记录Insert(insert into member_points_log (member_id, change_points, biz_type, biz_id, create_time) values (#{memberId}, #{points}, #{bizType}, #{bizId}, now())) int insertLog(Param(memberId) Long memberId, Param(points) Integer points, Param(bizType) String bizType, Param(bizId) Long bizId);bizType区分“点单赠送”还是“撤销扣回”bizId关联对应的订单id。这样到时候查积分余额对不上一条SQL就能追溯完整变更链。会员表的points字段可以留作为冗余余额但每次改动都要依赖流水表去核对。这一层设计在文档阶段容易被忽略却是系统上线后被问到最多的地方。5. 避坑笔记咖啡厅系统开发中的四个典型“翻车现场”这章记录我做过几套管理系统后遇到最多的四个问题。每一条都是现象、原因、解决三件套你能直接对着排查。5.1 事务内 try/catch 吞异常点单成功库存也扣了订单却没保存现象业务代码在创建订单时抛了一个SQL异常控制台能看到异常堆栈但数据库里库存已经减少订单表没有记录。原因Service方法上标了Transactional方法内部又用了try/catch把业务异常catch住继续往下走事务管理器根本没接收到异常信号。更隐蔽的情况是try/catch后没有把异常重新抛出Spring认为方法正常返回就把扣库存这个操作提交了。解决事务方法内不要写try/catch。如果需要在事务结束后记录日志把try/catch放到Controller层让Service层的异常自然往外抛。我给同事排查过不少类似问题最典型的就是网上教程里“统一异常处理”没做好学生自己兜底写了个catch然后打日志。记住了Transactional(rollbackFor Exception.class)只能回滚冒出去的异常吞掉的异常它管不了。5.2 逻辑删除字段没加到实体类原来能查到的订单突然全没了现象给orders表加了deleted字段后原来正常的订单列表查询突然返回空数组。原因全局配置里写了logic-delete-field: deletedMyBatis-Plus会对每个实体类自动追加deleted0条件。orders实体类里没写deleted字段就导致SQL变成WHERE deleted0而数据库里deleted字段如果允许NULL或者没有默认值已有记录全是NULLNULL0不成立所以查不到。解决所有参与逻辑删除的表实体类都要加private Integer deleted;字段。建表时给deleted加NOT NULL DEFAULT 0保证老数据也满足deleted0。如果只有个别表需要逻辑删除就别全局配置改成在对应实体类的deleted字段上加TableLogic注解指定删除值与未删除值。这个坑的特征是“加了逻辑删除后数据消失”非常容易误判成SQL写错。5.3 连接串没配 utf8mb4菜单里的“拿铁☕”存进去变成问号现象前端点菜名称显示正常提交后刷新页面数据库里的“拿铁☕”变成了“拿铁? ”读出来也是问号。原因建表时虽然用了utf8mb4但JDBC连接串里没加characterEncodingutf8mb4客户端连接会话用的是MySQL默认字符集4字节的emoji字符在写入时被转成问号或丢失。还有一个变体是连接串写了characterEncodingutf8这个编码本身不支持emoji。解决连接串里必须同时有useUnicodetrue和characterEncodingutf8mb4。如果你在IntelliJ IDEA或者DBeaver里直接查询显示正常但Java程序读出来乱码那就是连接串问题而不是表结构问题。数据已经变成问号的需要先修复连接串再重新修改对应记录不能靠ALTER TABLE解决存量数据。5.4 分页插件未生效接口返回全部记录总数一直是0现象Controller里传了Page对象但是返回的list把所有数据都拿出来total也是0前端分页完全失效。原因MyBatis-Plus分页依赖MybatisPlusInterceptor里注册的PaginationInnerInterceptor。如果没有注册或者注册了但类没被Spring扫描到Page对象就只是一个普通对象查询时不拼接LIMIT。还有一种情况是项目里同时有两个配置类都创建了MybatisPlusInterceptor后加载的覆盖了先前加载的。解决确认MybatisPlusConfig被ComponentScan扫描到最好放在启动类的子包下使用MybatisPlusInterceptor而不是老版PaginationInterceptor。排查时打开SQL日志看执行的语句是否带LIMIT如果带LIMIT但还是不生效再检查selectPage传入的Page参数是不是被重置了。这个坑在MyBatis-Plus 3.5.x里遇到最多早几年用3.1版本的老资料很容易误导。6. 上线前做一次并发点单验证库存和订单能不能对得上系统写完不是结束能通过并发验证才算真正站稳。我每次接手这类管理系统都会用一段再简单不过的脚本做对账模拟20并发点单然后看库存变化和订单数量是否一致。先准备一个接口可以创建订单。用bash直接并发调用# 模拟20个并发点单同一款咖啡库存50 for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/order/create \ -H Content-Type: application/json \ -d {dishId:1,count:1,memberId:1001} done wait脚本跑完后执行两条SQL验证数据-- 菜品id1的剩余库存预期从50变成30 SELECT stock FROM dish WHERE id 1; -- 最近一分钟内订单数量、明细总量 SELECT COUNT(*), COALESCE(SUM(item.count), 0) FROM order_item item INNER JOIN orders o ON item.order_id o.id WHERE o.create_time NOW() - INTERVAL 1 MINUTE;两条结果要能对上20个请求如果全部成功库存30订单表20条明细count之和20。如果出现库存30但订单数量少于20说明事务回滚了但扣库存没跟着回滚直接去查第5章第一节的try/catch问题。如果库存是负数说明原子SQL没写对查reduceStock的where条件是不是少了stock count。撤销验证同理把最近一笔订单ID传给取消接口执行后再查库存应该比撤销前多1订单状态变成2会员积分减少对应值。这个对账SQL我不删每次调整事务逻辑都重跑一遍。它能同时验证“并发扣减”和“补偿回滚”两条最敏感链路。这套验证方法不依赖JMeter、Postman这些工具一条命令加两条SQL就能在大屏幕或答辩现场演示观感也直接你告诉评委“扣库存是原子操作”不如让现场跑一次20并发后库存精确等于30。我习惯在所有事务改动后都先跑一遍这个对账不然数据库里数据对不上查起来比写代码难十倍。希望这个习惯也能帮到你至少让你在交付系统时不用靠“运气”保证数据一致。本文还有配套的精品资源点击获取
返回列表