苍穹外卖实战:套餐管理核心CRUD与状态流转的工程化实现
1. 套餐管理模块架构设计
在苍穹外卖系统中,套餐管理模块采用经典的三层架构设计,这种分层结构我在多个电商项目中验证过其稳定性。先来看整体架构:
- Controller层:接收前端请求,处理参数校验,调用Service层方法
- Service层:实现核心业务逻辑,处理事务控制
- Mapper层:负责数据库操作,通过MyBatis实现
实际开发中我习惯先设计领域模型,套餐管理涉及两个核心实体:
// 套餐主表实体 public class Setmeal { private Long id; private String name; private Long categoryId; // 分类ID private BigDecimal price; private Integer status; // 状态 1起售 0停售 // 其他字段... } // 套餐菜品关系实体 public class SetmealDish { private Long id; private Long setmealId; // 套餐ID private Long dishId; // 菜品ID private Integer copies; // 份数 // 其他字段... }数据库表设计遵循几个原则:
- 套餐表(setmeal)与菜品表(dish)通过中间表(setmeal_dish)建立多对多关系
- 套餐名称添加唯一索引确保唯一性
- 外键关联分类表(category)保证数据完整性
2. 新增套餐实现细节
新增套餐是典型的分布式事务场景,需要同时操作套餐主表和套餐菜品关系表。我在实际项目中遇到过因事务未处理导致的脏数据问题,这里分享解决方案:
@Transactional public void saveWithDish(SetmealDTO setmealDTO) { // 1. 保存套餐基本信息 Setmeal setmeal = new Setmeal(); BeanUtils.copyProperties(setmealDTO, setmeal); setmealMapper.insert(setmeal); // 使用@Options获取自增ID // 2. 保存套餐菜品关系 List<SetmealDish> dishes = setmealDTO.getSetmealDishes(); dishes.forEach(dish -> dish.setSetmealId(setmeal.getId())); setmealDishMapper.insertBatch(dishes); }几个关键点需要注意:
- 事务控制:使用@Transactional注解保证原子性
- 性能优化:批量插入代替循环单条插入
- 业务校验:
// 校验套餐名称唯一性 LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(Setmeal::getName, setmealDTO.getName()); if(setmealMapper.selectCount(queryWrapper) > 0){ throw new BusinessException("套餐名称已存在"); } // 校验关联菜品状态 setmealDTO.getSetmealDishes().forEach(dish -> { Dish dbDish = dishMapper.selectById(dish.getDishId()); if(dbDish == null || dbDish.getStatus() == 0){ throw new BusinessException("包含无效或停售菜品"); } });3. 套餐分页查询优化
分页查询是高频操作,经过多次性能测试后我总结出以下优化方案:
public PageResult pageQuery(SetmealPageQueryDTO dto) { // 使用PageHelper实现物理分页 PageHelper.startPage(dto.getPage(), dto.getPageSize()); // 动态SQL查询 Page<SetmealVO> page = setmealMapper.pageQuery(dto); return new PageResult(page.getTotal(), page.getResult()); }对应的XML映射文件:
<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>实际开发中容易踩的坑:
- N+1查询问题:避免在循环中查询关联数据
- 分页插件冲突:MyBatis-Plus与PageHelper不要混用
- 模糊查询优化:大数据量时考虑使用全文索引
4. 套餐状态流转设计
套餐的起售/停售是典型的状态模式应用场景,我通过状态机实现状态流转控制:
public void startOrStop(Integer status, Long id) { // 状态转换校验 Setmeal setmeal = setmealMapper.selectById(id); if(setmeal == null){ throw new BusinessException("套餐不存在"); } // 启售时校验菜品状态 if(status == StatusConstant.ENABLE){ List<Dish> dishes = dishMapper.getBySetmealId(id); if(dishes.stream().anyMatch(d -> d.getStatus() == StatusConstant.DISABLE)){ throw new BusinessException("套餐内包含停售菜品"); } } // 更新状态 Setmeal update = new Setmeal(); update.setId(id); update.setStatus(status); setmealMapper.update(update); // 清理缓存(如有) cleanCache(setmeal.getCategoryId()); }状态流转的注意事项:
- 并发控制:使用乐观锁防止状态覆盖
- 缓存一致性:状态变更后及时更新缓存
- 关联影响:套餐状态变更可能影响订单业务
5. 事务与异常处理实践
在套餐删除操作中,我采用声明式事务保证数据一致性:
@Transactional public void deleteBatch(List<Long> ids) { // 校验套餐状态 ids.forEach(id -> { Setmeal setmeal = setmealMapper.selectById(id); if(setmeal.getStatus() == StatusConstant.ENABLE){ throw new BusinessException("起售套餐不可删除"); } }); // 删除套餐记录 setmealMapper.deleteByIds(ids); // 删除关联菜品关系 setmealDishMapper.deleteBySetmealIds(ids); }异常处理的最佳实践:
- 自定义异常体系:
public class BusinessException extends RuntimeException { private Integer code; public BusinessException(String message) { super(message); this.code = 500; } }- 全局异常处理器:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result handleBusinessException(BusinessException ex){ return Result.error(ex.getMessage()); } }- 事务回滚策略:默认对RuntimeException回滚
6. 工程化实践建议
经过多个项目迭代,我总结出以下工程化实践:
- 接口文档管理:
- 使用Swagger生成实时API文档
- 配合YAPI进行接口Mock
- 代码规范:
- Controller方法不超过50行
- Service方法保持单一职责
- 复杂SQL写在XML中
- 性能监控:
// 使用AOP监控方法耗时 @Around("execution(* com.sky.service.impl.*.*(..))") public Object monitorTime(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); Object result = pjp.proceed(); log.info("方法执行耗时: {}ms", System.currentTimeMillis()-start); return result; }- 缓存策略:
- 套餐列表使用Redis缓存
- 采用Cache-Aside模式
- 设置合理的过期时间
在实际开发中,我发现套餐管理模块最常出现的问题是事务边界划分不清。建议按照以下原则划分事务:
- 跨表操作需要事务
- 查询方法不加事务
- 批量操作使用独立事务
最后分享一个调试技巧:在开发套餐状态流转时,可以使用Spring的@PostConstruct模拟状态变化:
@Component public class SetmealStatusDebug { @Autowired private SetmealService setmealService; @PostConstruct public void init(){ // 模拟启售操作 setmealService.startOrStop(1, 1L); } }