SpringBoot实战:从零构建外卖系统套餐管理模块

在当今快节奏的生活中,外卖平台已成为餐饮行业不可或缺的一部分。作为开发者,如何高效实现外卖系统的核心功能模块,是提升技术实力的重要途径。本文将聚焦"苍穹外卖"项目中的套餐管理模块,深入讲解如何使用SpringBoot和MyBatis实现完整的CRUD功能,特别关注事务处理与动态SQL等关键技术点。

1. 项目环境搭建与基础配置

在开始编码前,我们需要确保开发环境配置正确。以下是基础环境要求:

  • JDK 1.8+
  • Maven 3.6+
  • SpringBoot 2.7.x
  • MySQL 5.7+

依赖配置 (pom.xml关键部分):

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.2.2</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

数据库表设计是系统的基础,套餐管理涉及两个核心表:

setmeal表结构

CREATE TABLE `setmeal` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `category_id` bigint NOT NULL COMMENT '分类id',
  `name` varchar(32) NOT NULL COMMENT '套餐名称',
  `price` decimal(10,2) NOT NULL COMMENT '套餐价格',
  `status` int DEFAULT NULL COMMENT '状态 0:停用 1:启用',
  `description` varchar(255) DEFAULT NULL COMMENT '描述信息',
  `image` varchar(255) DEFAULT NULL COMMENT '图片',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `create_user` bigint DEFAULT NULL COMMENT '创建人',
  `update_user` bigint DEFAULT NULL COMMENT '修改人',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='套餐';

setmeal_dish表结构

CREATE TABLE `setmeal_dish` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `setmeal_id` bigint DEFAULT NULL COMMENT '套餐id',
  `dish_id` bigint DEFAULT NULL COMMENT '菜品id',
  `name` varchar(32) DEFAULT NULL COMMENT '菜品名称',
  `price` decimal(10,2) DEFAULT NULL COMMENT '菜品单价',
  `copies` int DEFAULT NULL COMMENT '份数',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='套餐菜品关系';

2. 套餐新增功能实现

新增套餐是系统中最复杂的操作之一,因为它需要同时处理套餐基本信息和套餐-菜品关联关系,必须保证数据一致性。

2.1 控制器层设计

@RestController
@RequestMapping("/admin/setmeal")
@Slf4j
@Api(tags = "套餐管理接口")
public class SetmealController {
    
    @Autowired
    private SetmealService setmealService;

    @PostMapping
    @ApiOperation("新增套餐")
    public Result<String> save(@RequestBody SetmealDTO setmealDTO) {
        log.info("新增套餐:{}", setmealDTO);
        setmealService.saveWithDishes(setmealDTO);
        return Result.success("套餐添加成功");
    }
}

2.2 服务层实现与事务控制

服务层需要处理两个关键操作:

  1. 保存套餐基本信息
  2. 保存套餐与菜品的关联关系

这两个操作必须在一个事务中完成:

@Service
@RequiredArgsConstructor
public class SetmealServiceImpl implements SetmealService {
    
    private final SetmealMapper setmealMapper;
    private final SetmealDishMapper setmealDishMapper;

    @Transactional
    @Override
    public void saveWithDishes(SetmealDTO setmealDTO) {
        // 1. 保存套餐基本信息
        Setmeal setmeal = new Setmeal();
        BeanUtils.copyProperties(setmealDTO, setmeal);
        setmealMapper.insert(setmeal);
        
        // 2. 保存套餐菜品关联信息
        List<SetmealDish> setmealDishes = setmealDTO.getSetmealDishes();
        setmealDishes.forEach(dish -> dish.setSetmealId(setmeal.getId()));
        setmealDishMapper.insertBatch(setmealDishes);
    }
}

关键点:@Transactional注解确保了两个数据库操作要么全部成功,要么全部回滚,避免了数据不一致的情况。

2.3 MyBatis动态SQL应用

批量插入套餐菜品关系时,使用MyBatis的foreach动态SQL:

<insert id="insertBatch">
    INSERT INTO setmeal_dish 
    (setmeal_id, dish_id, name, price, copies)
    VALUES
    <foreach collection="list" item="item" separator=",">
        (#{item.setmealId}, #{item.dishId}, #{item.name}, #{item.price}, #{item.copies})
    </foreach>
</insert>

3. 套餐查询功能实现

套餐查询功能包括分页查询和根据ID查询详情两种主要方式。

3.1 分页查询实现

分页查询DTO

@Data
public class SetmealPageQueryDTO {
    private Integer page;
    private Integer pageSize;
    private String name;
    private Long categoryId;
    private Integer status;
}

分页查询服务实现

@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());
}

MyBatis动态SQL

<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 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.2 套餐详情查询

套餐详情需要返回套餐基本信息和包含的菜品列表:

@Override
public SetmealVO getByIdWithDishes(Long id) {
    // 查询套餐基本信息
    Setmeal setmeal = setmealMapper.getById(id);
    
    // 查询套餐包含的菜品
    List<SetmealDish> dishes = setmealDishMapper.getBySetmealId(id);
    
    // 封装返回结果
    SetmealVO setmealVO = new SetmealVO();
    BeanUtils.copyProperties(setmeal, setmealVO);
    setmealVO.setSetmealDishes(dishes);
    
    return setmealVO;
}

4. 套餐修改与状态管理

4.1 套餐修改实现

套餐修改需要处理三个步骤:

  1. 更新套餐基本信息
  2. 删除原有菜品关联
  3. 新增当前菜品关联
@Transactional
@Override
public void updateWithDishes(SetmealDTO setmealDTO) {
    // 1. 更新套餐基本信息
    Setmeal setmeal = new Setmeal();
    BeanUtils.copyProperties(setmealDTO, setmeal);
    setmealMapper.update(setmeal);
    
    // 2. 删除原有菜品关联
    setmealDishMapper.deleteBySetmealId(setmealDTO.getId());
    
    // 3. 新增当前菜品关联
    List<SetmealDish> dishes = setmealDTO.getSetmealDishes();
    dishes.forEach(dish -> dish.setSetmealId(setmealDTO.getId()));
    setmealDishMapper.insertBatch(dishes);
}

4.2 套餐起售与停售

套餐状态变更需要特别注意业务规则:

  • 停售套餐可以直接停售
  • 起售套餐需要检查包含的菜品是否都已启售
@Override
public void updateStatus(Integer status, Long id) {
    // 如果是起售操作,需要检查套餐中的菜品状态
    if (status == StatusConstant.ENABLE) {
        List<Dish> dishes = dishMapper.getBySetmealId(id);
        if (dishes != null && !dishes.isEmpty()) {
            dishes.forEach(dish -> {
                if (dish.getStatus() == StatusConstant.DISABLE) {
                    throw new SetmealEnableFailedException("套餐中包含未启售菜品,无法启售");
                }
            });
        }
    }
    
    // 更新套餐状态
    Setmeal setmeal = Setmeal.builder()
            .id(id)
            .status(status)
            .updateTime(LocalDateTime.now())
            .build();
    setmealMapper.update(setmeal);
}

5. 套餐删除功能实现

套餐删除需要考虑以下业务规则:

  1. 起售中的套餐不能删除
  2. 需要同时删除套餐和菜品关联关系
@Transactional
@Override
public void deleteBatch(List<Long> ids) {
    // 检查套餐是否可删除
    ids.forEach(id -> {
        Setmeal setmeal = setmealMapper.getById(id);
        if (setmeal.getStatus() == StatusConstant.ENABLE) {
            throw new DeletionNotAllowedException("套餐正在售卖中,不能删除");
        }
    });
    
    // 删除套餐和关联菜品
    ids.forEach(id -> {
        setmealMapper.deleteById(id);
        setmealDishMapper.deleteBySetmealId(id);
    });
}

6. 最佳实践与性能优化

在实际开发中,我们还需要考虑以下优化点:

6.1 事务隔离级别选择

@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public void saveWithDishes(SetmealDTO setmealDTO) {
    // 方法实现
}

提示:对于外卖系统这类并发量中等的应用,READ_COMMITTED隔离级别通常是最佳选择。

6.2 批量操作优化

对于套餐菜品关联的大量数据插入,可以采用以下优化方式:

// 分批插入,每批100条
List<List<SetmealDish>> partitions = Lists.partition(setmealDishes, 100);
partitions.forEach(setmealDishMapper::insertBatch);

6.3 缓存策略

对于高频访问的套餐数据,可以考虑引入Redis缓存:

@Cacheable(value = "setmeal", key = "#id")
public SetmealVO getByIdWithDishes(Long id) {
    // 方法实现
}

@CacheEvict(value = "setmeal", key = "#setmealDTO.id")
public void updateWithDishes(SetmealDTO setmealDTO) {
    // 方法实现
}

7. 异常处理与日志记录

完善的异常处理是健壮系统的重要组成部分:

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler
    public Result<String> handleException(SetmealEnableFailedException ex) {
        log.error("套餐启售失败:{}", ex.getMessage());
        return Result.error(ex.getMessage());
    }
    
    @ExceptionHandler
    public Result<String> handleException(DeletionNotAllowedException ex) {
        log.error("删除操作不允许:{}", ex.getMessage());
        return Result.error(ex.getMessage());
    }
}

日志记录应当包含足够的上下文信息:

log.info("开始处理套餐删除,套餐ID列表:{}, 操作人:{}", ids, SecurityContext.getCurrentUserId());

在实现外卖系统套餐管理模块时,我发现最容易被忽视的是事务边界的设计。特别是在套餐修改这种涉及多表操作的场景,明确的事务划分可以避免许多潜在的数据一致性问题。另一个实践中的经验是,对于状态变更这类业务规则复杂的操作,前置校验比事后补偿要可靠得多。

更多推荐