SpringBoot实战:手把手教你搞定苍穹外卖的套餐管理CRUD(含事务处理与MyBatis动态SQL)
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 服务层实现与事务控制
服务层需要处理两个关键操作:
- 保存套餐基本信息
- 保存套餐与菜品的关联关系
这两个操作必须在一个事务中完成:
@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 套餐修改实现
套餐修改需要处理三个步骤:
- 更新套餐基本信息
- 删除原有菜品关联
- 新增当前菜品关联
@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. 套餐删除功能实现
套餐删除需要考虑以下业务规则:
- 起售中的套餐不能删除
- 需要同时删除套餐和菜品关联关系
@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());
在实现外卖系统套餐管理模块时,我发现最容易被忽视的是事务边界的设计。特别是在套餐修改这种涉及多表操作的场景,明确的事务划分可以避免许多潜在的数据一致性问题。另一个实践中的经验是,对于状态变更这类业务规则复杂的操作,前置校验比事后补偿要可靠得多。
更多推荐


所有评论(0)