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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;2.2 表关系解析
套餐模块涉及的表关系网络如下:
分类表(category) ← 一对多 → 套餐表(setmeal) 套餐表(setmeal) ← 一对多 → 套餐菜品关系表(setmeal_dish) 套餐菜品关系表(setmeal_dish) ← 多对一 → 菜品表(dish)这种设计的关键考虑点:
- 冗余字段设计:在setmeal_dish表中冗余存储了菜品名称和价格,避免每次查询都需要联表
- 逻辑外键:使用逻辑外键而非物理外键,提高性能同时保持数据完整性
- 状态控制:通过status字段实现灵活的起售/停售控制
提示:在实际开发中,对于高频查询但很少变更的数据(如菜品名称、价格),适当使用冗余字段可以显著提升查询性能。
3. 套餐分页查询实现
3.1 接口设计与DTO定义
分页查询需要支持以下筛选条件:
- 套餐名称模糊查询
- 分类ID精确查询
- 状态查询
- 分页参数
定义查询DTO:
@Data 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 Result<PageResult> 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()); Page<SetmealVO> page = setmealMapper.pageQuery(setmealPageQueryDTO); return new PageResult(page.getTotal(), page.getResult()); } }Mapper层
<select id="pageQuery" resultType="com.sky.vo.SetmealVO"> select s.*, c.name as categoryName from setmeal s left join category c on s.category_id = c.id <where> <if test="name != null and name != ''"> and s.name like concat('%',#{name},'%') </if> <if test="categoryId != null"> and s.category_id = #{categoryId} </if> <if test="status != null"> and s.status = #{status} </if> </where> order by s.create_time desc </select>3.3 踩坑记录
问题1:页面上的最后操作时间不显示
原因:返回的VO类型错误,使用了没有updateTime字段的DTO
解决:统一使用SetmealVO作为返回类型
问题2:SQL报错"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. 保存套餐菜品关系 List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes(); if (CollectionUtils.isNotEmpty(setmealDishes)) { setmealDishes.forEach(dish -> dish.setSetmealId(setmealId)); setmealMapper.insertBatch(setmealDishes); } }Mapper层
<insert id="insert" useGeneratedKeys="true" keyProperty="id"> insert into setmeal (...) values (...) </insert> <insert id="insertBatch"> insert into setmeal_dish (...) values <foreach collection="list" item="item" separator=","> (#{item.setmealId}, #{item.dishId}, ...) </foreach> </insert>4.3 关键问题解决
问题:无法获取插入后生成的套餐ID
原因:MyBatis插入操作未配置返回主键
解决:在insert语句中添加useGeneratedKeys="true" keyProperty="id"
技巧:对于批量插入操作,使用MyBatis的foreach标签可以显著提高性能。实测显示,批量插入比单条循环插入快5-10倍。
5. 删除套餐实现
5.1 业务规则
删除套餐时需要:
- 检查套餐状态(起售中的套餐不能删除)
- 删除套餐主表记录
- 删除关联的套餐菜品关系记录
- 记录操作日志
5.2 代码实现
Service层
@Transactional @Override public void deleteBatch(List<Long> 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 id="countByIdsAndStatus" resultType="integer"> select count(*) from setmeal where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> and status = #{status} </select> <delete id="deleteByIds"> delete from setmeal where id in <foreach collection="ids" item="id" separator="," open="(" close=")"> #{id} </foreach> </delete> <delete id="deleteBySetmealIds"> delete from setmeal_dish where setmeal_id in <foreach collection="ids" item="id" separator="," open="(" close=")"> #{id} </foreach> </delete>5.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. 插入新的菜品关系 List<SetmealDish> 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的高级用法有了更深入的理解。