
1. 项目概述在餐饮管理系统的开发过程中套餐管理模块是核心业务功能之一。这个模块需要实现套餐的完整生命周期管理包括新增、查询、修改、删除以及状态控制等功能。作为开发人员我在实现苍穹外卖系统的套餐管理模块时遇到了不少典型的技术问题和业务逻辑挑战本文将详细记录这些实战经验。套餐管理不同于单菜品管理它涉及到多表关联操作和复杂的事务处理。一个完整的套餐通常包含基础信息名称、价格、分类等包含的菜品组合销售状态控制操作日志记录这些业务特性使得套餐管理模块的开发需要考虑更多细节特别是在数据库设计和事务控制方面。下面我将从数据库设计开始逐步拆解各个功能的实现过程。2. 数据库设计与表关系2.1 核心表结构设计套餐管理涉及两个核心表套餐主表(setmeal)和套餐菜品关系表(setmeal_dish)。这种设计遵循了数据库设计的范式原则同时考虑了查询性能。setmeal表结构CREATE TABLE setmeal ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, name varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT 套餐名称, category_id bigint NOT NULL COMMENT 分类id, price decimal(10,2) NOT NULL COMMENT 套餐价格, image varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 图片路径, description varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 套餐描述, status int DEFAULT 1 COMMENT 售卖状态 1起售 0停售, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 最后修改时间, create_user bigint NOT NULL COMMENT 创建人id, update_user bigint NOT NULL COMMENT 最后修改人id, PRIMARY KEY (id), UNIQUE KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_bin;setmeal_dish表结构CREATE TABLE setmeal_dish ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, setmeal_id bigint NOT NULL COMMENT 套餐id, dish_id bigint NOT NULL COMMENT 菜品id, name varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 菜品名称冗余字段, price decimal(10,2) DEFAULT NULL COMMENT 菜品单价冗余字段, copies int DEFAULT NULL COMMENT 菜品份数, PRIMARY KEY (id), KEY idx_setmeal_id (setmeal_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_bin;2.2 表关系解析套餐模块涉及的表关系网络如下分类表(category) ← 一对多 → 套餐表(setmeal) 套餐表(setmeal) ← 一对多 → 套餐菜品关系表(setmeal_dish) 套餐菜品关系表(setmeal_dish) ← 多对一 → 菜品表(dish)这种设计的关键考虑点冗余字段设计在setmeal_dish表中冗余存储了菜品名称和价格避免每次查询都需要联表逻辑外键使用逻辑外键而非物理外键提高性能同时保持数据完整性状态控制通过status字段实现灵活的起售/停售控制提示在实际开发中对于高频查询但很少变更的数据如菜品名称、价格适当使用冗余字段可以显著提升查询性能。3. 套餐分页查询实现3.1 接口设计与DTO定义分页查询需要支持以下筛选条件套餐名称模糊查询分类ID精确查询状态查询分页参数定义查询DTOData public class SetmealPageQueryDTO { private String name; private Long categoryId; private Integer status; private Integer page; private Integer pageSize; }3.2 三层架构实现Controller层RestController RequestMapping(/admin/setmeal) Api(tags 套餐管理接口) Slf4j public class SetmealController { Autowired private SetmealService setmealService; GetMapping(/page) ApiOperation(套餐分页查询) public ResultPageResult page(SetmealPageQueryDTO setmealPageQueryDTO) { log.info(分页查询套餐{}, setmealPageQueryDTO); PageResult pageResult setmealService.pageQuery(setmealPageQueryDTO); return Result.success(pageResult); } }Service层Service Slf4j public class SetmealServiceImpl implements SetmealService { Autowired private SetmealMapper setmealMapper; Override public PageResult pageQuery(SetmealPageQueryDTO setmealPageQueryDTO) { PageHelper.startPage(setmealPageQueryDTO.getPage(), setmealPageQueryDTO.getPageSize()); PageSetmealVO page setmealMapper.pageQuery(setmealPageQueryDTO); return new PageResult(page.getTotal(), page.getResult()); } }Mapper层select idpageQuery resultTypecom.sky.vo.SetmealVO select s.*, c.name as categoryName from setmeal s left join category c on s.category_id c.id where if testname ! null and name ! and s.name like concat(%,#{name},%) /if if testcategoryId ! null and s.category_id #{categoryId} /if if teststatus ! null and s.status #{status} /if /where order by s.create_time desc /select3.3 踩坑记录问题1页面上的最后操作时间不显示原因返回的VO类型错误使用了没有updateTime字段的DTO解决统一使用SetmealVO作为返回类型问题2SQL报错Column status in where clause is ambiguous原因在多表查询时status字段未指定表别名解决所有字段都加上表别名前缀如s.status经验在多表联查时养成给所有字段添加表别名的习惯可以避免字段歧义问题。4. 新增套餐实现4.1 业务流程分析新增套餐的核心流程保存套餐基本信息保存套餐包含的菜品列表记录操作日志这是一个典型的事务性操作需要使用Transactional保证数据一致性。4.2 关键代码实现Controller层PostMapping ApiOperation(新增套餐) public Result save(RequestBody SetmealDTO setmealDTO) { log.info(新增套餐{}, setmealDTO); setmealService.saveWithDishes(setmealDTO); return Result.success(); }Service层Transactional Override public void saveWithDishes(SetmealDTO setmealDTO) { // 1. 保存套餐基本信息 Setmeal setmeal new Setmeal(); BeanUtils.copyProperties(setmealDTO, setmeal); setmealMapper.insert(setmeal); // 2. 获取生成的套餐ID Long setmealId setmeal.getId(); // 3. 保存套餐菜品关系 ListSetmealDish setmealDishes setmealDTO.getSetmealDishes(); if (CollectionUtils.isNotEmpty(setmealDishes)) { setmealDishes.forEach(dish - dish.setSetmealId(setmealId)); setmealMapper.insertBatch(setmealDishes); } }Mapper层insert idinsert useGeneratedKeystrue keyPropertyid insert into setmeal (...) values (...) /insert insert idinsertBatch insert into setmeal_dish (...) values foreach collectionlist itemitem separator, (#{item.setmealId}, #{item.dishId}, ...) /foreach /insert4.3 关键问题解决问题无法获取插入后生成的套餐ID原因MyBatis插入操作未配置返回主键解决在insert语句中添加useGeneratedKeystrue keyPropertyid技巧对于批量插入操作使用MyBatis的foreach标签可以显著提高性能。实测显示批量插入比单条循环插入快5-10倍。5. 删除套餐实现5.1 业务规则删除套餐时需要检查套餐状态起售中的套餐不能删除删除套餐主表记录删除关联的套餐菜品关系记录记录操作日志5.2 代码实现Service层Transactional Override public void deleteBatch(ListLong ids) { // 1. 检查套餐状态 Integer count setmealMapper.countByIdsAndStatus(ids, StatusConstant.ENABLE); if (count 0) { throw new SetmealEnableFailedException(存在起售中的套餐无法删除); } // 2. 删除套餐记录 setmealMapper.deleteByIds(ids); // 3. 删除关联菜品 setmealMapper.deleteBySetmealIds(ids); }Mapper层select idcountByIdsAndStatus resultTypeinteger select count(*) from setmeal where id in foreach collectionids itemid open( separator, close) #{id} /foreach and status #{status} /select delete iddeleteByIds delete from setmeal where id in foreach collectionids itemid separator, open( close) #{id} /foreach /delete delete iddeleteBySetmealIds delete from setmeal_dish where setmeal_id in foreach collectionids itemid separator, open( close) #{id} /foreach /delete5.3 事务控制要点方法添加Transactional注解先查询后删除的顺序不能颠倒批量删除使用IN语句而非循环单条删除异常处理要明确避免事务回滚不生效6. 修改套餐实现6.1 业务逻辑分析修改套餐是最复杂的操作需要考虑查询回显数据更新套餐基本信息处理菜品列表变更可能涉及增删改状态变更的特殊处理6.2 关键代码实现Service层Transactional Override public void updateWithDishes(SetmealDTO setmealDTO) { // 1. 更新套餐基本信息 Setmeal setmeal new Setmeal(); BeanUtils.copyProperties(setmealDTO, setmeal); setmealMapper.update(setmeal); // 2. 删除原有菜品关系 setmealMapper.deleteBySetmealIds(Collections.singletonList(setmealDTO.getId())); // 3. 插入新的菜品关系 ListSetmealDish setmealDishes setmealDTO.getSetmealDishes(); if (CollectionUtils.isNotEmpty(setmealDishes)) { setmealDishes.forEach(dish - dish.setSetmealId(setmealDTO.getId())); setmealMapper.insertBatch(setmealDishes); } }状态修改单独处理PostMapping(/status/{status}) ApiOperation(套餐起售停售) public Result startOrStop(PathVariable Integer status, Long id) { setmealService.startOrStop(status, id); return Result.success(); } // Service实现 Override public void startOrStop(Integer status, Long id) { // 起售时需要检查套餐下是否有菜品 if (status StatusConstant.ENABLE) { Integer count setmealDishMapper.countBySetmealId(id); if (count 0) { throw new SetmealEnableFailedException(套餐中没有菜品无法起售); } } Setmeal setmeal Setmeal.builder() .id(id) .status(status) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build(); setmealMapper.update(setmeal); }6.3 性能优化建议对于菜品列表变更采用先删后增的策略比逐条比对修改更高效状态变更单独处理避免不必要的字段更新使用Builder模式构建更新对象减少不必要的属性拷贝7. 常见问题与解决方案7.1 事务失效场景场景1非public方法添加Transactional解决确保事务方法都是public的场景2自调用问题解决避免在类内部调用自己的事务方法场景3异常被捕获未抛出解决确保异常能传播到事务切面7.2 MyBatis使用技巧批量操作使用foreach标签返回主键使用useGeneratedKeys复杂查询使用resultMap进行映射动态SQL使用if/choose等标签7.3 联调常见问题字段名不一致导致数据无法绑定时间格式转换问题空集合处理不当导致NPE分页参数传递错误在实际开发中我发现套餐管理模块最关键的三个技术点是事务控制、批量操作效率和异常处理。特别是在处理套餐修改时如何优雅地处理菜品列表的变化需要仔细设计。通过这次项目实践我对Spring事务管理和MyBatis的高级用法有了更深入的理解。