news 2026/9/13 12:11:19

Spring Boot餐饮管理系统源码解析:从Service分层到支付回调

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot餐饮管理系统源码解析:从Service分层到支付回调

简介:这是一套基于Spring Boot的餐饮管理系统Java毕业设计源码及数据库文件,面向计算机、通信、人工智能、自动化等专业的学生与从业者,适用于课程设计、期末大作业或毕业设计参考。项目覆盖菜品管理、套餐管理、员工管理、购物车、订单及报表等常见业务模块,代码结构清晰,经过调试可运行,答辩评审分为98分,具备较好的学习与二次开发价值。压缩包共161个文件,以139个Java源文件为主,另有XML配置、YAML配置文件以及SQL数据库脚本,便于理解Spring Boot分层架构与数据库设计。资源包大小约153KB,轻量易部署。目前已有346人浏览学习,适合基础薄弱者逐步阅读核心Service实现,也适合有能力的开发者在此基础上定制功能。

1. 为什么值得拆这套 Spring Boot 餐饮管理系统

当拿到一份餐饮管理系统源码时,不要急着点 Run,先把源码里的类按业务跑道分一遍。这套基于 Spring Boot 的 Java 毕业设计源码里出现了 OrderServiceImpl、DishServiceImpl、SetmealServiceImpl、ShoppingCartServiceImpl,说明订单、菜品、套餐、购物车是四条已经拆干净的链路;再加上 ReportServiceImpl、WorkspaceServiceImpl、WeChatPayUtil,看得到统计报表和支付回调也被纳入了同一工程。这比常见的单表 CRUD 毕设要完整得多。它既能当数据库课程设计的参考,也能作为 Spring Boot 框架课的进阶练习。下面我直接从核心实现类入手,讲清楚模块怎么协作、表怎么设计、踩坑时看哪里。

2. Spring Boot 分层设计:菜品、套餐与购物车的实现边界

2.1 接口与实现分离:Service 层的职责是什么

看这套源码的类名就能猜出设计习惯:每个业务模块配一个 Service 接口和一个 ServiceImpl 实现类,例如 DishService / DishServiceImpl。这是 Spring 项目里最常见的装配方式,也是 Spring Boot 面试题里经常被问到的“为什么用接口”。

提示:如果项目后期要把实现类替换成 RPC 客户端或者 Mock 实现,接口是唯一能保住调用方不动的保险丝。

ServiceImpl 里不直接拼 SQL,而是调用 Mapper;Controller 层只接收参数并返回 VO,不在 Controller 里写业务。这是我拆代码时最想看到的分层,因为后续改菜单分类、改套餐价格,只会动 service 层,不会污染接口层。像 EmployeeServiceImpl 这种看似没有复杂查询的类,也值得单独看,它通常负责员工登录校验、账号状态禁用和密码加密,很多项目直接把明文密码传给后端,我一般会用 BCrypt 做哈希存储,避免数据库泄露后口令被直接捞走。

2.2 DishServiceImpl 的查询、分页与上下架

菜品模块是系统里最常被模仿的一段代码。举一个分页查询的例子,常见做法是用 PageHelper 或者 MyBatis-Plus 的分页插件:

public PageResult<DishVO> pageQuery(DishPageQuery query) { // 第 1 个参数是页码,从 1 开始;第 2 个参数是每页条数,建议限制上限。 PageHelper.startPage(query.getPage(), query.getPageSize()); List<Dish> dishList = dishMapper.selectByCondition(queryToWrapper(query)); // PageHelper 会把 List 包装成 Page,里面带 total 和页码信息 Page<Dish> page = (Page<Dish>) dishList; return new PageResult<>(page.getTotal(), page.getResult()); }

这段代码里真正的 SQL 在 dishMapper.xml 中,通常是动态拼接。比如菜品名称做 LIKE 查询,分类 ID 做等值查询:

<select id="selectByCondition" resultType="com.example.entity.Dish"> SELECT id, name, category_id, price, status, update_time FROM dish <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY update_time DESC </select>

这里 IO 开销不在 SQL 本身,而在LIKE '%keyword%'无法使用普通索引。数据量小于十万条的时候可以接受;如果要做成生产系统,可以引入 Elasticsearch 或者 MySQL 全文索引。参数里status为 1 表示上架,为 0 表示停售,查询时经常用它过滤掉停售菜品。

2.3 SetmealServiceImpl 与套餐多对多明细

套餐和菜品不是简单的主外键关系,而是一张关联表。这套源码里的setmeal_dish表通常包含setmeal_iddish_idcopies三个核心字段。保存套餐时,要先保存套餐主信息,获得主键后再批量插入明细,否则无法把套餐与多个菜品绑定。

我一般会在SetmealServiceImpl里用事务包裹这两步:

@Transactional(rollbackFor = Exception.class) public Long saveWithDishes(SetmealDTO dto) { // 保存套餐主表,mybatis 的 useGeneratedKeys 会把自增 id 回填到 dto 中。 setmealMapper.insert(dto); List<SetmealDish> dishes = dto.getSetmealDishes(); for (SetmealDish dish : dishes) { dish.setSetmealId(dto.getId()); setmealDishMapper.insert(dish); } return dto.getId(); }

如果没有这层事务,套餐主表写进去而明细失败,前台会出现“空套餐”。接口里如果还要求同步维护菜品销量或库存,务必要考虑锁放在哪一层。对于纯展示型的套餐,用乐观锁或版本号即可;对于下单减库存模型,需要更细粒度的行锁。

下面是一张常见表结构参考:

表名关键字段说明
setmealid, name, category_id, price, status套餐主表
setmeal_dishid, setmeal_id, dish_id, copies套餐与菜品关联,copies 表示份数
dishid, name, category_id, price, status菜品基础数据
categoryid, type, name, sort菜品/套餐分类,type 区分菜品还是套餐

套餐修改时不能只 update 主表,应该删除旧明细再重新插入,否则会留下脏关联数据。

2.4 ShoppingCartServiceImpl:数量合并与口味隔离

购物车是业务闭环里最容易产生脏数据的一环。如果每次加入都新增一条记录,用户反复点同一个菜会生成多行数据,结算时用户体验很差。更好的做法是进入购物车时先按用户 ID、菜品 ID 和口味去重;如果存在则数量 +1,不存在才插入新记录。

public ShoppingCart add(ShoppingCart cart) { // 先查当前用户是否有相同商品 ShoppingCart exist = cartMapper.selectOneByUserAndDish( cart.getUserId(), cart.getDishId(), cart.getSetmealId(), cart.getDishFlavor()); if (exist != null) { exist.setNumber(exist.getNumber() + 1); cartMapper.updateById(exist); return exist; } cart.setNumber(1); cartMapper.insert(cart); return cart; }

参数里dishFlavor的作用常常被忽略,比如“微辣”“少冰”属于同一菜品但不同口味,若不加这个字段,合并数量会把两种口味挤在一起。另一个坑是套餐与单品同时在购物车里,必须通过setmealId区分,因为同一行套餐明细对应多个菜品,不能用菜品 ID 直接覆盖。收银台渲染购物车时,还需要把setmealId为空的记录归为单品,非空的归为套餐。

3. 订单、支付与报表:状态机、事务和回调验签

3.1 OrderServiceImpl 的下单事务边界

订单是整套系统里最值得细看的部分。下单至少涉及以下动作:查询购物车、计算总金额、生成订单主表、生成订单明细表、清空购物车。如果过程中某个 Mapper 抛异常,不能让订单主表保留但明细丢失,所以我看到OrderServiceImpl的下单方法上都会加@Transactional(rollbackFor = Exception.class)

下面的代码展示了典型流程:

@Transactional(rollbackFor = Exception.class) public Long submitOrder(OrderSubmitDTO dto) { List<ShoppingCart> cartList = cartMapper.listByUserId(dto.getUserId()); if (cartList == null || cartList.isEmpty()) { throw new BusinessException("购物车不能为空"); } // 生成订单主表 Orders order = new Orders(); order.setNumber(OrderNoGenerator.next()); order.setUserId(dto.getUserId()); order.setAddress(dto.getAddress()); order.setAmount(cartList.stream() .map(ShoppingCart::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add)); // 1. 先写主表,拿到自增主键 orderMapper.insert(order); // 2. 写明细表 List<OrderDetail> detailList = cartList.stream() .map(item -> createOrderDetail(order.getId(), item)) .collect(Collectors.toList()); orderDetailMapper.batchInsert(detailList); // 3. 清空购物车 cartMapper.deleteByUserId(dto.getUserId()); return order.getId(); }

这里必须注意事务的边界:如果下单时还要调用微信支付统一下单接口,我会把“生成订单”和“提交到微信”拆成两步,先落库订单再调远程接口。远程接口超时会导致数据库被动长事务,锁竞争会明显上升。更多时候的做法是在订单表中增加status字段,下单成功先置为“待支付”,收到支付回调后再改为“已支付”。

订单状态建议用整数枚举而不是字符串,至少包含:

状态值含义触发动作
0待支付提交订单后初始状态,超时做关单
1已支付支付回调成功后写入
2已完成用户确认或商家点击完成
3已取消用户主动取消或超时关闭
4退款中售后发起退款,等待原路返回

3.2 WeChatPayUtil 与 HttpClientUtil 的对接方式

源码里的WeChatPayUtil基本是围绕微信支付 API 做签名和参数组装,真正发请求的工作要交给HttpClientUtil。以统一下单为例,核心是利用 HttpClient 发送一个 JSON 请求到微信支付接口:

public String prePay(String orderNo, BigDecimal amount, String openid) { Map<String, String> params = new HashMap<>(); params.put("appid", appId); params.put("mch_id", mchId); params.put("out_trade_no", orderNo); params.put("total_fee", amount.multiply(new BigDecimal("100")).intValue() + ""); params.put("body", "餐饮-订单"); // 通过 addPaySign 生成 md5 签名并写回 params String xml = buildXml(params); String responseXml = httpClientUtil.doPost( "https://api.mch.weixin.qq.com/pay/unifiedorder", xml, "application/xml"); return parseXml(responseXml).getString("prepay_id"); }

这里total_fee必须转换为分,且不能直接传字符串,否则金额会被错误解析。HttpClientUtil在实现上要设置连接超时和读取超时,我建议统一下单连接超时 3 秒、读取超时 10 秒。还有一点容易被忽视:mch_idappid必须配置在统一配置类里,不要写在控制器里,否则后面换商户号要改一堆文件。

支付回调的处理更要小心。微信回调通知的是同一个地址,需要先验证签名,再判断订单状态,防止重复通知导致重复更新。

public void handleNotify(String requestBody) { // 1. 先校验签名,签名失败直接返回签名错误 boolean check = weChatPayUtil.verifyNotify(requestBody); if (!check) { throw new BusinessException("签名校验失败"); } // 2. 取出 out_trade_no,判断当前订单状态 Orders order = orderMapper.selectByNumber(outTradeNo); if (order.getStatus() != 0) { // 已经是已支付,直接返回成功,避免重复处理 return; } order.setStatus(1); order.setPayTime(LocalDateTime.now()); orderMapper.updateById(order); }

回调逻辑里的幂等性必须在事务外先判断再更新,否则并发时两个通知线程可能同时读到待支付状态,造成重复更新库存。

3.3 ReportServiceImpl 与 WorkspaceServiceImpl:统计口径和缓存策略

报表模块看起来只是几个求和查询,实际上最容易写出慢 SQL。源码里的ReportServiceImpl负责营业额、订单数、销量排行等,而WorkspaceServiceImpl偏向工作台今天有多少单、有多少待处理。两者都依赖订单表的create_timestatus字段。

大部分统计查询可以归纳成下面这种按天分组的 SQL:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS sale_date, SUM(CASE WHEN status = 1 THEN amount ELSE 0 END) AS turnover, COUNT(*) AS order_count FROM orders WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY sale_date;

注意这里SUM(CASE WHEN status = 1 ...)只统计已支付订单,排除了待支付和已取消的单子。当数据量增长后,这种 SQL 必须把时间条件加上,并且要在create_time上加普通索引。如果需要实时性不高的营业概览,我一般会在 Service 层加一层缓存,比如 30 秒到 1 分钟的快照缓存,避免每次刷新工作台都去扫订单表。

跨天的统计还有一个细节:餐饮系统的营业时间往往从凌晨开始,比如到凌晨 2 点属于前一天营业日。简单按自然日分组并不准确,需要在 Service 层先把开始时间减 2 小时再分组,或者直接把“营业日”规则写入报表查询条件。

4. 数据库建模、索引设计与启动排错

4.1 核心表关系与关键索引

看这套代码的表结构,基本围绕 employee、category、dish、setmeal、setmeal_dish、shopping_cart、orders、order_detail 这些表展开。employee 负责后台登录;category 分菜品分类和套餐分类;dish 和 setmeal 独立建表;购物车和订单都以用户 ID 作为核心维度。

以订单表为例,最常用到的查询条件是用户查询自己的订单、后台按状态和时间范围筛选。因此orders表至少要有三个索引:user_idstatuscreate_timeorder_detail的主键和order_id分别建索引。很多毕设在演示时只有几百条数据看不出问题,答辩官现场导入上万条数据后,缺失索引的查询立刻变慢。

下面是常见表结构和索引建议:

表名常用查询建议索引
employee按用户名查员工idx_username, idx_status
orders按用户查历史订单idx_user_id, idx_status, idx_create_time
order_detail按订单 ID 查明细idx_order_id
dish按分类和状态查询idx_category_id, idx_status
setmeal_dish按套餐 ID 查询idx_setmeal_id
shopping_cart按用户查购物车idx_user_id

这里需要注意联合索引和单列索引的取舍。如果每天要跑一次“某时间段内已支付订单营业额”,单独在statuscreate_time上建索引效率不如(status, create_time)联合索引,因为查询条件先走状态过滤再按时间排序。但当前厨后台需要频繁按订单状态查列表时,联合索引也更合适。

4.2 Mapper 层 ResultMap 与一对多映射

订单详情是典型的一对多场景:一条订单对应多条明细。为了减少查询次数,通常会在 Mapper XML 里写一个ResultMap来嵌套集合。

<resultMap id="OrderWithDetailResultMap" type="com.example.entity.Orders"> <id property="id" column="id"/> <result property="number" column="order_number"/> <collection property="details" ofType="com.example.entity.OrderDetail"> <id property="id" column="detail_id"/> <result property="dishName" column="dish_name"/> <result property="amount" column="detail_amount"/> </collection> </resultMap>

用这种collection映射时,SQL 必须保证 order 主表字段与 detail 字段的别名不冲突,否则会覆盖主表数据。另一种更解耦的做法是先查orders列表,再按订单 ID 批量查order_detail,在 Service 层做内存分组。这种折中方式在订单量大时能避免 MySQL 返回大量冗余列,两个查询各走各自的索引,综合性能并不差。

4.3 本地启动排错:装载数据库、时区和端口

拿到源码第一步是确认数据库版本。项目如果使用 MySQL 5.7 环境和默认utf8字符集,遇到生僻字或 Emoji 时会出现Incorrect string value,需要把字段或表改成utf8mb4。Spring Boot 配置文件中常见的连接串如下:

spring: datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456

第三个参数serverTimezone一定要填,否则 MySQL 8 版本时区缺失会直接报The server time zone value 'Öйú±ê׼ʱ¼ä'allowPublicKeyRetrieval=true解决 MySQL 8 驱动连接时出现的公钥检索错误。如果前端和后端都在本地跑,还要检查 Spring Boot 默认端口 8080 是否被占用,最直接的排查命令是:

lsof -i :8080 netstat -ano | grep 8080

如果是 Maven 工程,建议先执行mvn clean package -DskipTests打包,报缺依赖再看仓库是否处于内网代理状态。源码已经调试过的说法并不代表所有环境都能一键跑通,最常见的隐患是 JDK 版本与 Spring Boot 2.x 不匹配,Spring Boot 2.6 以下建议用 JDK 8,Spring Boot 3.x 则必须 JDK 17 以上。

5. 二次开发切入点:缓存、参数校验和工作台演示

5.1 用 Spring Cache 给工作台做本地缓存

WorkspaceServiceImpl每次刷新都会统计今日订单数、营业额、待处理订单,数据实时性要求不高,完全可以用 Spring Cache 减少数据库压力。在方法上直接加注解:

@Cacheable(cacheNames = "workspace:overview", key = "#userId") public WorkspaceVO getOverview(Long userId) { return buildOverview(userId); }

cacheNames可以理解为缓存分区,不同模块用不同分区避免 key 冲突;key = "#userId"表示每个后台用户一份缓存。这种方式适合单机部署,如果以后拆成多实例,要换成 Redis 分布式缓存,只需要把缓存管理器从ConcurrentMapCacheManager换成RedisCacheManager,注解本身不需要改。

5.2 给菜品接口加上参数校验

扩展自己的功能时,最值得补的一层是参数校验,否则非法数据会在 Service 层才暴露。常见做法是在DishSaveDTO中加注解:

public class DishSaveDTO { @NotBlank(message = "菜品名称不能为空") private String name; @NotNull(message = "分类不能为空") private Long categoryId; @DecimalMin(value = "0.01", message = "价格最小为0.01") private BigDecimal price; }

Controller 中加上@Validated @RequestBody DishSaveDTO dto就能在进入 Service 前拦截。答辩演示时,故意传一个空名称和负价格,能直观展示你的健壮性;比“接口报 500”更可控。验证缓存是否生效的方法很简单:连续两次调用同一个工作台统计接口,第二次看后台日志不再打印订单表 SQL,说明缓存命中。这时候再改数据库里的订单数据,短时间内统计不会变,就能确认过期时间参数生效。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 12:11:03

Cap Mobile iOS 开发指南:Expo Router、EAS 构建与 OTA 更新全流程

Cap Mobile iOS 开发指南&#xff1a;Expo Router、EAS 构建与 OTA 更新全流程 【免费下载链接】Cap Open source Loom alternative. Beautiful, shareable screen recordings. 项目地址: https://gitcode.com/GitHub_Trending/cap1/Cap Cap Mobile 是开源屏幕录制项目 …

作者头像 李华
网站建设 2026/9/13 12:10:04

Spring Boot健康检查与监控实践指南

1. Spring Boot健康检查与监控概述在微服务架构中&#xff0c;服务健康状态监控是保障系统稳定性的关键环节。Spring Boot通过Actuator模块提供了开箱即用的健康检查能力&#xff0c;让开发者能够快速构建完善的监控体系。我在多个生产项目中实践发现&#xff0c;合理的健康检查…

作者头像 李华
网站建设 2026/9/13 12:09:33

英飞凌CoolGaN量产方案:驱动-器件协同设计实战指南

1. 项目概述&#xff1a;为什么英飞凌这次发布的不是“概念样品”&#xff0c;而是真正能上产线的氮化镓方案&#xff1f; 最近在电源设计圈里&#xff0c;好几个老同事发来消息问&#xff1a;“听说英飞凌新推的CoolGaN方案能直接量产了&#xff1f;是不是真的不用再自己搭驱动…

作者头像 李华
网站建设 2026/9/13 12:09:31

彻底卸载OpenClaw:进程、配置、Docker数据卷一网打尽

如果你电脑上装过 OpenClaw&#xff08;就是大家常说的“龙虾”&#xff09;&#xff0c;应该能感受到它是个相当能折腾的 AI 智能体框架——接微信、挂网关、调度各种模型&#xff0c;确实好玩。但等你想卸载的时候&#xff0c;才是真正头疼的开始&#xff1a;命令行里敲which…

作者头像 李华
网站建设 2026/9/13 12:08:46

软硬件协同设计实现无人机低功耗优化

1. 项目概述&#xff1a;当无人机芯片开始“省电模式”&#xff0c;MIT团队做对了什么&#xff1f;低功耗不是靠调低电压、关几个外设就完事的——那是硬件工程师的直觉&#xff0c;不是系统级的解法。真正让小型无人机续航翻倍、发热骤降、飞行更稳的&#xff0c;是软硬件之间…

作者头像 李华