
从年初到现在我前前后后带了三十多个计算机毕业设计的学生其中至少三分之一选的题目都和“咖啡店销售系统”沾边。倒不是说这个题目有多新颖主要是它恰到好处地踩中了JavaWeb技术栈的覆盖范围SpringBoot做后端、MySQL存数据、前端来一套管理页面难度适中业务场景又贴近生活评委一听就能理解学生答辩时也容易讲清楚。但问题恰恰出在“贴近生活”这四个字上。很多学生拿到题目后第一反应是打开IDEA新建一个SpringBoot项目然后照着网上的商城Demo改改商品表和订单表最后做出一个四不像——说它是咖啡店系统吧里面既没有门店概念也没有制作状态流转更没有原料库存的联动说它是电商系统吧又没有复杂的营销和物流模型。我见过最夸张的一个整个系统里只有一个“咖啡表”和一个“订单表”做完之后连他自己都说不清门店里那个点单大屏的数据是从哪儿来的。这篇文章我就以“基于SpringBoot的咖啡饮品在线订购与门店管理平台”为例把从需求拆解、表设计到接口实现、避坑优化的完整链路讲一遍。写这篇文章的用户画像我默认是两类人一类是正在做毕业设计、需要一套能落地能讲清楚的方案的学生另一类是刚接触JavaWeb开发、想拿一个真实业务练手的初级开发者。我会刻意让整个流程严格贴合“咖啡店”这个场景来展开因为只有把业务细节抠出来代码才有灵魂。1. 别急着建项目先把“卖一杯咖啡”这件事拆成状态流转我每次给学生辅导第一个问题永远是你能不能把顾客从进店到拿到咖啡的全过程用流程图在纸上画出来凡是能画出来的系统做出来八九不离十凡是画不出来直接开写代码的后面基本都要推翻重来。线下咖啡店和纯电商最大的差别在于商品不是一锤子买卖的标品它有一个明确的“制作周期”需要参与者的实时协作。我按自己对咖啡店业务的理解把核心流程拆成了这样顾客浏览菜单选择自取或外送提交订单并完成支付门店端收到新订单进入待制作队列咖啡师接单开始制作制作完成如果是自取则通知顾客取餐如果是外送则进入等骑手取货订单完成归档这一串动作翻译成状态机就是订单实体上的一个状态字段在流转待支付、已支付、待接单、制作中、待取餐或配送中、已完成、已取消。这里有一个很多初学者容易漏掉的点用户下单之后如果一直不支付订单占着库存怎么办所以状态机里必须有“超时关闭”这个分支通常的做法是给订单增加一个过期时间字段配合定时任务把超过15到30分钟未支付的订单自动关掉。也别小看这个细节我在下面的优化章节里会专门讲它该怎么实现才不坑。再说回实体识别。围绕上述流程我们会自然拆出这些业务实体门店、用户会员、商品咖啡、SKU规格如大杯/中杯、热/冰、原料库存、订单、订单明细、支付流水。每张表之间都是能顺着业务逻辑走通的不像很多网上Demo表和表之间是断的。如果你也正在做这个题目我建议你开工前花一个晚上干这件事用一张A4纸把上面这个流程用泳道图画出来左边是顾客中间是系统右边是门店/咖啡师然后把你脑海中出现的每一个名词都记下来。名词就是表的候选动词就是接口的候选。这个方法我用了八年带过的大多数顺利通过答辩的学生靠的都是这张纸。2. 项目骨架怎么搭取决于SpringBoot版本和持久层框架选型2.1 SpringBoot版本不是越新越好2.7.x依旧是稳妥选择我为什么推荐直接说版本因为在“springboot版本太高”成为热词之前我就已经被学生用新版SpringBoot折腾过很多回了。SpringBoot 3.x发布之后Java EE的包名从javax改成了jakarta很多老教程里的PageHelper、druid之类的配置就直接没法用了。对毕业设计来说你需要的不是最新特性而是遇到问题能在十分钟内搜到答案的生态。所以我给的选型建议是SpringBoot 2.7.18 JDK 8或11。这个组合到了什么程度呢你随便在CSDN或者博客园搜一个问题几乎都能找到对应解决方案。而且2.7.x是3.x之前最后一个稳定的2.x大版本官方维护周期也足够长拿来交毕设或者做中小型项目练习完全没有问题。同时还要提醒一个问题IDEA新建SpringBoot项目时Spring Initializr默认可能会指向3.x版本。在国内网络环境下你甚至连页面都加载不出来。解决方法是把Server URL换成阿里云的镜像地址https://start.aliyun.com。这里生成的SpringBoot默认是2.7.x刚好符合我们的预期。具体创建路径不重要重点是你进入项目的第一个动作应该是检查pom.xml里的parent版本号锁定合理范围。2.2 持久层框架选MyBatis-Plus核心原因是省心MyBatis-Plus这个选择我基本不给学生犹豫的机会。一方面它的BaseMapper帮你把单表CRUD全包了你只需要写业务相关的复杂SQL另一方面它自带分页插件、条件构造器、字段自动填充这些功能可以让学生少写几百行模板代码把精力留给真正的业务逻辑。我知道肯定有人会说JPA更方便但现实是毕业设计答辩老师大概率默认学生用的是MyBatis系列。你用MP至少不用额外解释“为什么你的Repository没有实现类”这种容易陷入争论的问题。依赖层面一个最小可运行的SpringBoot咖啡店项目核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependency如果要加Redis做缓存或分布式锁再加spring-boot-starter-data-redis不过那是后话。对第一版跑通业务来说这一套就够了。我在实际辅导中还会让学生额外把spring-boot-starter-validation加上。别以为参数校验无关紧要我后面讲订单接口时你会看到没有Validation的话一个空指针能让你查半天。2.3 经典的目录结构实体、Mapper、Service、Controller各自归位很多教程喜欢把目录结构吹得特别神秘什么DDD分层、什么六边形架构。但毕业设计的代码核心原则只有一句话让任何一个人打开你的项目两分钟之内能找到他想找的类。我惯用的模块划分是这样com.example.coffee ├── common # 统一返回结果、异常处理、常量 ├── config # 配置类MybatisPlus、WebMvc等 ├── controller # 接口层 ├── service # 业务层 │ └── impl ├── mapper # 数据访问层 ├── entity # 数据库实体 ├── dto # 入参出参对象 └── util # 工具类有人觉得entity、dto、vo分来分去很麻烦我理解但这一步省不得。直接拿实体类当接收参数的后果是一旦接口的入参多于表字段你就得在实体上添加TableField(exist false)这种字段时间一长整个实体变成垃圾场排查起来非常痛苦。3. 数据表设计直接决定系统的天花板订单和SKU是重中之重3.1 咖啡不是单一商品它是“商品规格”的组合这是我对所有做过咖啡店系统的学生的第一个考察点。如果你建的coffee表里只有id、name、price三个字段不用继续往后看我的建议是果断重构。一杯咖啡在真实门店里至少有杯型中杯、大杯、温度冰、热、糖度标准糖、半糖、无糖以及是否浓缩加份这几个维度。不同维度组合会导致两个关键结果价格可能不同大杯通常加三到五元制作工时和原料用量完全不同。所以你需要一张商品表描述“这是什么饮品”再一张SKU表描述“具体哪个规格的饮品售卖多少钱”。用行话讲商品表是SPUStandard Product Unit标准产品单元SKU表是Stock Keeping Unit库存量单位。这个模型在电商系统里是标配但在咖啡店项目里很多学生就是想不到用。我举一个简单例子一张“经典拿铁”在菜单上是一行但它向下延展出至少四个可选规格商品名SKU规格售价元经典拿铁中杯/冰28经典拿铁大杯/冰33经典拿铁中杯/热27经典拿铁大杯/热32这套设计配合前端的一组单选按钮就可以优雅地实现菜单的点选交互。后端在接收下单请求时传的不再是“商品ID”而是“SKU ID 数量”才能保证订单金额和门店制作单准确无误。3.2 订单主表和订单明细表的拆分逻辑只要订单里会出现至少两种不同的商品主表明细表就永远是正确解。主表存一次下单的公共信息比如订单号、总金额、状态、门店ID、用户ID、下单时间、支付时间明细表存每一行商品的具体信息SKU ID、商品名称冗余字段、规格描述冗余字段、单价、数量、小计。这里我特别强调冗余字段。为什么明细表里要重复存储商品名称和规格描述而不是下单时只存一个SKU ID因为商品和SKU的名称价格今后可能调整如果明细表只关联ID一个月后你去查历史订单会发现“当时到底买的什么”已经无从考证了。而对账、门店销售统计、用户历史复购分析全靠明细表里这份冗余快照扛着。这是包括电商在内无数系统验证过的实践经验。下面给一个适用于本项目的最小化DDL模板我控制一下字段数量方便复刻CREATE TABLE sku ( id bigint(20) NOT NULL AUTO_INCREMENT, product_id bigint(20) NOT NULL COMMENT 商品ID, 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint(20) NOT NULL, store_id bigint(20) NOT NULL, total_amount decimal(10,2) NOT NULL, status tinyint(4) NOT NULL COMMENT 0待支付 1已支付 2制作中 3待取餐 4已完成 5已关闭, pay_time datetime DEFAULT NULL, expire_time datetime NOT NULL COMMENT 支付过期时间, created_time datetime NOT NULL, updated_time datetime NOT 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, sku_id bigint(20) NOT NULL, sku_name varchar(128) NOT NULL COMMENT 冗余快照, price decimal(10,2) NOT NULL COMMENT 下单时单价, quantity int(11) NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.3 金额用decimal(10,2)订单号用业务规则而不是自增ID金额类型必须用十进制定点数这个我说过无数次具体原因很简单float和double是浮点数在二进制环境下无法精确表示0.1这样的值如果涉及多行明细求和再和总金额比对会得到让人一头雾水的“0.99999999”。虽然Java层的BigDecimal可以在一定程度上兜住这事但最优雅的做法是数据库字段直接定义成decimal(10,2)从源头杜绝精度丢失。订单号就不要用自增主键直接暴露给用户了。一是容易让竞争对手估算你的单量二是自增ID太容易猜测存在越权风险。我惯用的生成规则是日期时间 门店编号 随机数/自增序列。比如202412081530 001 758124格式统一、可读性强、还能直接看出是哪家门店的单。生成这个不要用Math.random()拼容易撞最简单方式是借助UUID截取后六位数字或者用Redis的INCR生成当日自增序列。4. 核心接口链路从菜单到支付回调每个节点都有抗并发要求4.1 获取菜单缓存和库存状态一次给全第一个接口是顾客端启动后会调用的“获取门店菜单”它在业务上不复杂就是查SPUSKU按分类返回。但这里有个体验层面的细节菜单接口返回的不仅是名称、价格、图片、描述还必须包含每一个SKU的“可售状态”。也就是说如果某一个SKU库存为0前端应该在界面上置灰。如果返回了库存数量那前端就要做相应展示有些门店喜欢显示“今日售罄”逻辑是一样的。初期版本可以每次实时查库但随着门店量增多、并发量上来菜单接口会成为第一个被压垮的瓶颈。我的做法是在实现时顺手把Redis派上用场以门店ID为key缓存菜单结构SKU库存变化时删除对应缓存定时任务每隔几分钟重建一次。这一步对答辩来说也是亮点用缓存扛高并发读这个表述在技术评审时永远是加分的。在这个环节我想额外提一个问题图片怎么办很多学生本地图片能显示一到答辩换机器就全裂了。我强烈建议把图片以Base64字符串或URL的形式放在数据库里并在配置文件中设置虚拟路径映射到本地上传目录。这属于最容易被忽视的“地基问题”我后面会专门出一段说。4.2 创建订单减库存、算金额、填明细必须在一个事务里这应该是整个系统最核心的代码段也是我开始让学生“战战兢兢”的地方。一个正确的下单接口除了接收用户ID、门店ID、商品列表之外至少要干五件事检查每个SKU是否上架、是否够库存根据SKU当前售价计算每一行小计以及订单总金额总金额禁止前端传过来必须后端算不然用户把price改成0.01就白嫖了扣减库存这一步要在数据库层面做原子操作不是先查再改生成订单主记录和明细记录状态置为待支付返回订单ID和过期时间供前端跳转支付其中第3步是整篇文章的高危预警区。很多学生的第一版代码长这样Sku sku skuMapper.selectById(skuId); if (sku.getStock() num) { throw new BizException(库存不足); } sku.setStock(sku.getStock() - num); skuMapper.updateById(sku);这个逻辑在单机测试没有问题一旦有两个用户同时点最后一杯咖啡两个线程都查出库存是1都通过校验都执行减1最终库存变成了0。表面上不超卖但订单已经创建了两单其中一单实际无货可做。要解决必须直接把判断和更新合并成一条SQLint updated skuMapper.deductStock(skuId, num); if (updated 0) { throw new BizException(库存不足); }对应的Mapper语句是update iddeductStock update sku set stock stock - #{num} where id #{skuId} and stock #{num} /update通过受影响行数来判断到底扣没扣成这才是原子性的正确姿势。配合Transactional把五个动作包在同一个事务里才能保证“扣了库存但订单创建失败”这种脏数据永远不出现。顺便提一句第3步查出库存的那个select是可以保留的用来返回友好提示但绝不能作为唯一校验手段。4.3 支付回调用订单状态CAS代替繁琐的分布式锁因为这个系统对接真实支付渠道比较麻烦毕设阶段通常用“模拟支付”或“沙箱支付”代替。但即便是模拟你也要把回调接口的姿势写对。这里的关键点在于回调是一次性的幂等操作不允许同一笔订单被重复回执成“已支付”。我推荐最简单可靠的方案在支付服务的更新语句里加上状态条件。boolean success updateOrderStatusToPaid(orderNo, expireTime);对应的SQL核心是这个样子update idupdateStatusToPaid update orders set status 1, pay_time now() where order_no #{orderNo} and status 0 /update这样无论回调被触发多少次只有第一次能成功把状态从“待支付”换成“已支付”后续的更新永远匹配不到status 0的记录天然幂等。这也避免了为了一个模拟支付引入Redisson分布式锁的过度设计。4.4 门店端的订单看板轮询还是WebSocket门店端的核心页面是一个“订单工作台”上面实时滚动新订单。最简单的实现是前端每隔3到5秒调用一次“查询指定状态订单列表”的接口比如GET /store/orders?status1。这在数据量不大、门店数不多的情况下完全够用。但我建议如果学有余力可以升级成WebSocket实时推送。SpringBoot对WebSocket的支持属于开箱即用级别只需要一个配置类注册Endpoint并在服务端订单状态变化时向对应门店的会话推送一条消息即可。这一升级在答辩时非常好讲因为你能把“实时性”这个非功能性需求落到技术实现上。如果你时间不够轮询方案也足以应付毕竟毕设的本质是证明你会做而不是证明你做到大厂标准。5. 我在实际项目里踩过的坑以及对应的兜底方案这一部分我本来不想单独展开但复盘下来发现这些坑具有高度普适性——几乎每个写咖啡店系统的学生都会至少踩中两个。我列成清单模式你写的时候直接对照检查会节省大量调试时间。5.1 库存和销量对不上事务位置放错有学生问过我一个经典问题老师我的扣库存没有用上面说的原子SQL只是把updateById放进了Transactional方法里为什么还是超了因为Transactional解决的是要么全部成功要么全部失败它不解决并发下的“查询后更新”竞态问题。要理解这个把它类比成“两个人同时看中了同一张自习室座位”——事务保证的是其中一个人刷卡成功后座位状态从“空闲”变成“占用”但能不能防止两个人同时看到“空闲”取决于你的判断和执行之间是不是一个不可拆分的动作。所以再次强调库存扣减必须用条件更新SQL完成判断和扣减的原子合并。5.2 状态流转混乱更新时没带条件订单状态从待支付到已支付、再到制作中、已完成每个流转都应该是单向的。如果更新时只写UPDATE orders SET status #{newStatus} WHERE id #{id}如果一个用户退款请求和门店完成请求同时触发状态就可能不是你想的那个结果。因为后执行的更新无条件覆盖了前一个。稳妥的做法是所有状态流转的UPDATE都携带“当前状态条件”类似我上面支付回调里的写法。状态回流被挡在SQL层而不是依赖代码的调用顺序这会极大降低出Bug概率。5.3 时间字段的坑UTC和北京时间差八小时新手出现“数据库时间比实际时间晚8小时”的原因主要有两个一是数据库连接串里没有配置serverTimezoneAsia/Shanghai二是默认的Json序列化时区不对。第一个问题比较容易排查。你在application.yml里写MySQL连接时顺手把时区参数写到连接串上即可url: jdbc:mysql://localhost:3306/coffee?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai第二个问题可以通过配置spring.jackson.time-zoneGMT8解决。我还建议所有时间字段统一使用datetime类型并且依赖MyBatis-Plus的TableField(fill FieldFill.INSERT)自动填充创建时间避免每个Service里手动写new Date()既冗余又容易漏。5.4 本地图片地址变成死链配置虚拟映射路径在SpringBoot里图片上传后存储在本地磁盘的某个目录下如果你直接把D:/upload/xxx.jpg存进数据库前端拿到这个地址根本没法访问因为Tomcat并不会把你磁盘的绝对路径暴露为静态资源。解决办法是在WebMvcConfig里注册一个虚拟映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceHandler(file: uploadDir); } }这样数据库中只需存储/upload/xxx.jpg前端拼接域名即可访问。把这个细节做对你会发现答辩演示时再也不用因为图片裂掉而尴尬。5.5 前端传对象直接绑定实体天生的越权入口如果Controller里直接用Order实体类去接收前端POST过来的JSON前端只需要多传一个id字段就可能导致数据被串改。所有写操作接口都应通过自定义的DTO接收参数然后由Service层完成DTO到实体的转换。这不是洁癖而是安全底线。我见过不止一个学生接口名叫“创建订单”但因为前端多传了一个orderId字段直接把一座历史订单给覆盖了。这个教训希望你不用亲身经历也能记住。6. 给答辩加分的三个优化方向菜单缓存、延时关单和销量看板讲完那些兜底方案再来说说怎么把系统里已有的模块做得更漂亮一点。这三个方向不用全做挑两个做深做好答辩时的技术含金量立刻就不一样了。6.1 菜单接口的Redis缓存策略刚进入门店页面第一件发生的事情就是拉取菜单。如果每个顾客每次进来都查一遍SPU表、SKU表、分类表数据库压力会被无效读放大。更合理的缓存策略是用门店ID做维度把菜单整体结构序列化存进Redis。当管理员修改商品信息或上下架SKU时删除对应门店维度的缓存key让下一次请求重新加载。这里有几个实际操作的细节要注意Redis缓存value统一用JSON字符串缓存更新用“先更新数据库再删除缓存”而不是“先删缓存再更新数据库”能少踩很多并发脏读的坑同时给缓存key设置合理过期时间作为兜底比如所有门店菜单统一缓存30分钟避免缓存长期不刷新。做好这三点你写出来的代码就不是“为了用Redis而用Redis”而是能自圆其说的真实性能优化方案。6.2 超时未支付订单的关闭方案我前面提到过订单创建后30分钟还没支付就应该自动关闭并回补库存。这个需求的常见实现是写一个Spring定时任务每分钟扫描一次所有超过过期时间且状态为待支付的订单批量关闭。在数据量小的毕业设计里这么写完全够用。Scheduled(cron 0 * * * * ?) public void closeExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredUnpaid(); for (Order order : expiredOrders) { try { orderService.closeOrder(order.getOrderNo()); } catch (Exception e) { log.error(关闭超时订单失败: {}, order.getOrderNo(), e); } } }唯一要注意的是在SpringBoot应用主类上别忘了加EnableScheduling。不是第一次见到学生把定时任务代码写得完美结果类上少一个注解导致任务死活不执行排查了一整晚才发现是这里的问题。这类小细节别人不问你就永远不会注意到但答辩老师只要一句“你这个定时任务为什么能跑起来”就能把你问住。6.3 门店销售看板聚合查询统计今日营业额这个功能严格来说不是核心业务但所有评委都喜欢看。一个销售看板把当日订单数、营业额、热销商品Top5、时段分布折线图数据一次返回整个系统的完整度立刻上升一个档次。写几个合理的SQL查询配合MyBatis-Plus的分页、group by条件能非常快地实现出来select idsumTodaySales resultTypejava.math.BigDecimal SELECT IFNULL(SUM(total_amount), 0) FROM orders WHERE store_id #{storeId} AND status IN (2, 3, 4) AND created_time #{startTime} /select热销商品的统计稍微复杂一点要join一下订单明细表select idtopProducts resultTypemap SELECT oi.sku_name AS name, SUM(oi.quantity) AS total FROM order_item oi INNER JOIN orders o ON o.id oi.order_id WHERE o.store_id #{storeId} AND o.status IN (2, 3, 4) AND o.created_time #{startTime} GROUP BY oi.sku_id ORDER BY total DESC LIMIT 5 /select这套看板数据接口加上一个简单的前端图表库比如ECharts三张柱状图加一张折线图视觉冲击力比十个增删改查都大。而且它不需要额外的技术栈SQL本身就是你业务理解能力的证据答辩时逐行讲解比看PPT更让人信服。7. 从“能跑”到“讲得清”最后打磨阶段的几点经验代码写完只是第一步答辩时能不能把系统的设计逻辑讲清楚往往比代码本身更重要。我带学生模拟答辩的时候经常问三个问题你如果现在能顺畅地回答出来基本就不用慌张第一为什么订单要用主从表结构因为一个订单会包含多件不同规格的商品主表记录一次交易的汇总信息明细表记录每行商品的快照和数量。这样查订单列表只扫主表就够快查订单详情再关联明细表存储和查询的复杂度都合理。第二库存是怎么防止超卖的因为扣减操作是一条条件更新SQL数据库在更新时会锁住对应的行只有库存大于等于购买数量时才会更新成功抢不到的行自然受更新结果为0的影响事务回滚不会出现实际库存为负的脏数据。第三订单状态为什么使用数值枚举而不是字符串因为状态是有限的、离散的用数值存储更省空间查询和比较更高效并且配合一个状态枚举类可以非常清晰地管理每一个状态允许流转到哪些状态。配合我前面说的带状态条件的UPDATE能够天然防止非法流转。把这三个问题回答好你的毕业设计就不是“做完”而是“吃透了”。我再补充一句个人实操心得每次改完状态流转或库存相关逻辑自己手动做一遍“用户下单、支付回调、门店接单、制作完成”的完整流程测试把数据库里的订单表、库存表的数据截图存下来。别小看这个过程一方面它能帮你提前暴露许多逻辑漏洞另一方面这些截图是你答辩PPT里最有说服力的素材比任何架构图都强。