1. 苍穹外卖项目中的套餐管理模块概述
在餐饮行业的外卖系统中,套餐管理是商家后台的核心功能之一。作为"苍穹外卖"项目的开发者,我在实现套餐管理模块时遇到了几个典型的技术问题,这些问题主要集中在分页查询实现和数据库设计方面。
套餐管理模块需要处理的主要业务场景包括:
- 套餐的创建、编辑和上下架
- 套餐与单品的关联关系维护
- 套餐的分页展示与条件查询
- 套餐库存的实时更新
这个模块的技术栈基于Spring Boot + MyBatis-Plus + MySQL,前端使用Vue.js。在开发过程中,我发现MyBatis-Plus的分页插件配置和套餐关联查询的实现最容易出现问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分页查询报错分析与解决方案
2.1 典型报错现象重现
在实现套餐分页查询功能时,控制台出现了以下报错信息:
code复制org.apache.ibatis.exceptions.PersistenceException:
Error querying database. Cause: java.lang.IllegalArgumentException:
PageSize must not be less than one!
这个错误表明分页参数中的pageSize被设置为了小于1的值。经过排查,我发现问题出在前端传递的分页参数没有被正确校验。
2.2 分页查询的正确实现方式
要解决这个问题,我们需要从以下几个方面入手:
- MyBatis-Plus分页插件配置:
java复制@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
- Controller层参数校验:
java复制@GetMapping("/page")
public Result<Page<Setmeal>> page(int page, int pageSize, String name) {
// 参数校验
if(page <= 0) page = 1;
if(pageSize <= 0) pageSize = 10;
Page<Setmeal> pageInfo = new Page<>(page, pageSize);
setmealService.pageQuery(pageInfo, name);
return Result.success(pageInfo);
}
- Service层实现:
java复制@Override
public void pageQuery(Page<Setmeal> pageInfo, String name) {
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(StringUtils.isNotEmpty(name), Setmeal::getName, name)
.orderByDesc(Setmeal::getUpdateTime);
this.page(pageInfo, queryWrapper);
}
2.3 分页查询的常见陷阱
在实际开发中,分页查询还有几个容易踩的坑:
- N+1查询问题:当需要关联查询套餐包含的菜品时,如果不注意会触发N+1查询
- 排序字段选择:使用更新时间作为排序字段时,可能出现相同更新时间导致分页结果不稳定
- 大数据量分页性能:当数据量很大时,LIMIT offset, size方式的性能会急剧下降
提示:对于关联查询,建议使用MyBatis-Plus的
@TableField(exist = false)注解配合自定义SQL解决,避免N+1问题。
3. 套餐管理的数据库设计问题
3.1 套餐表与菜品表的关联设计
在苍穹外卖项目中,套餐与菜品是多对多关系。我最初设计的数据库结构如下:
sql复制CREATE TABLE `setmeal` (
`id` bigint NOT NULL 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 NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='套餐';
CREATE TABLE `setmeal_dish` (
`id` bigint NOT NULL COMMENT '主键',
`setmeal_id` bigint NOT NULL COMMENT '套餐id',
`dish_id` bigint NOT NULL COMMENT '菜品id',
`name` varchar(32) DEFAULT NULL COMMENT '菜品名称(冗余字段)',
`price` decimal(10,2) DEFAULT NULL COMMENT '菜品单价(冗余字段)',
`copies` int NOT NULL COMMENT '份数',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_setmeal_dish` (`setmeal_id`,`dish_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COMMENT='套餐菜品关系';
3.2 遇到的典型报错及解决
在实现套餐保存功能时,遇到了以下报错:
code复制java.sql.SQLIntegrityConstraintViolationException:
Duplicate entry '135246-142857' for key 'idx_setmeal_dish'
这个错误是由于尝试向setmeal_dish表插入重复的套餐-菜品组合导致的。解决方案是在业务逻辑层添加检查:
java复制public void saveWithDish(SetmealDto setmealDto) {
// 保存套餐基本信息
this.save(setmealDto);
// 获取套餐菜品关系列表
List<SetmealDish> setmealDishes = setmealDto.getSetmealDishes();
setmealDishes.forEach(item -> item.setSetmealId(setmealDto.getId()));
// 检查是否已存在相同组合
LambdaQueryWrapper<SetmealDish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SetmealDish::getSetmealId, setmealDto.getId())
.in(SetmealDish::getDishId,
setmealDishes.stream().map(SetmealDish::getDishId).collect(Collectors.toList()));
if(setmealDishService.count(queryWrapper) > 0) {
throw new CustomException("套餐中已包含部分菜品,请勿重复添加");
}
// 批量保存套餐菜品关系
setmealDishService.saveBatch(setmealDishes);
}
4. 套餐状态管理的并发问题
4.1 套餐上下架的并发控制
在实现套餐上下架功能时,如果不考虑并发控制,可能会出现状态不一致的问题。典型场景是:
- 管理员A查询到套餐状态为"启用"
- 管理员B同时查询到套餐状态为"启用"
- 管理员A将套餐状态改为"停用"
- 管理员B也将套餐状态改为"停用"
- 最终套餐状态与预期不符
4.2 解决方案:乐观锁实现
为了解决这个问题,我采用了MyBatis-Plus的乐观锁机制:
- 在套餐表中添加version字段:
sql复制ALTER TABLE `setmeal` ADD COLUMN `version` int DEFAULT '0' COMMENT '乐观锁版本号';
- 实体类中添加@Version注解:
java复制@Version
private Integer version;
- 更新操作时自动检查版本号:
java复制@PostMapping("/status/{status}")
public Result<String> updateStatus(@PathVariable Integer status, @RequestParam List<Long> ids) {
setmealService.updateStatus(status, ids);
return Result.success("套餐状态修改成功");
}
// Service实现
@Transactional
public void updateStatus(Integer status, List<Long> ids) {
List<Setmeal> setmeals = this.listByIds(ids);
setmeals.forEach(setmeal -> {
setmeal.setStatus(status);
});
this.updateBatchById(setmeals);
}
5. 套餐管理模块的性能优化
5.1 缓存策略设计
为了提高套餐查询性能,我引入了Redis缓存:
- 缓存数据结构设计:
java复制// 套餐分类缓存key设计
String key = "setmeal:category:" + categoryId;
// 缓存数据结构示例
{
"id": 1,
"name": "超值套餐",
"price": 38.00,
"description": "包含主食+饮料+小食",
"image": "https://...",
"dishes": [
{
"id": 101,
"name": "香辣鸡腿堡",
"copies": 1
},
// 其他菜品...
]
}
- 缓存更新策略:
- 新增/修改套餐时:删除对应分类的缓存
- 查询套餐时:先查缓存,不存在则查数据库并写入缓存
5.2 批量操作优化
对于套餐的批量操作,如批量上下架,我优化了SQL执行方式:
java复制@Transactional
public void updateStatusBatch(Integer status, List<Long> ids) {
// 使用UpdateWrapper批量更新
UpdateWrapper<Setmeal> updateWrapper = new UpdateWrapper<>();
updateWrapper.in("id", ids)
.set("status", status)
.set("update_time", LocalDateTime.now());
this.update(updateWrapper);
// 清除相关缓存
ids.forEach(id -> {
Setmeal setmeal = this.getById(id);
String cacheKey = "setmeal:category:" + setmeal.getCategoryId();
redisTemplate.delete(cacheKey);
});
}
6. 前端与后端的交互问题
6.1 参数传递格式问题
在前端调用套餐分页查询接口时,曾出现过以下报错:
code复制org.springframework.web.method.annotation.MethodArgumentTypeMismatchException:
Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer'
这个问题是由于前端传递的page和pageSize参数虽然是数字,但实际以字符串形式传输导致的。解决方案有两种:
- 前端修改:确保传递的是JSON格式参数
javascript复制axios.get('/setmeal/page', {
params: {
page: 1, // 数字类型
pageSize: 10 // 数字类型
}
})
- 后端修改:使用@RequestParam注解明确指定参数类型
java复制@GetMapping("/page")
public Result<Page<Setmeal>> page(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name) {
// ...
}
6.2 文件上传大小限制
在套餐图片上传功能中,遇到了文件大小限制的问题:
code复制org.springframework.web.multipart.MaxUploadSizeExceededException:
Maximum upload size exceeded; nested exception is java.lang.IllegalStateException:
org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException:
The field image exceeds its maximum permitted size of 1048576 bytes.
解决方案是在application.yml中调整上传限制:
yaml复制spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
7. 测试阶段的常见问题
7.1 单元测试中的事务问题
在编写套餐服务的单元测试时,遇到了事务不回滚的问题:
code复制org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
这是因为测试方法中嵌套了多个事务导致的。解决方案是:
- 在测试类上添加事务配置:
java复制@SpringBootTest
@Transactional
@Rollback
public class SetmealServiceTest {
// ...
}
- 对于需要验证异常的场景,使用expected属性:
java复制@Test(expected = CustomException.class)
public void testSaveWithDuplicateDish() {
// 测试重复添加菜品的场景
}
7.2 集成测试中的数据准备
套餐管理的集成测试需要准备大量测试数据,我采用了以下策略:
- 使用@TestConfiguration配置测试数据源
- 在@Before方法中初始化基础数据
- 使用DBUnit管理测试数据集
java复制@Before
public void setup() {
// 初始化分类数据
Category category = new Category();
category.setName("测试分类");
category.setType(2); // 套餐分类
categoryMapper.insert(category);
// 初始化菜品数据
Dish dish = new Dish();
dish.setName("测试菜品");
dish.setPrice(new BigDecimal("10.00"));
dish.setCategoryId(category.getId());
dishMapper.insert(dish);
}
在完成套餐管理模块的开发后,我总结了几个关键经验:分页参数必须严格校验、多对多关系要防止重复、状态变更要考虑并发、缓存设计要合理失效。这些经验对于其他类似功能的开发也有很好的参考价值。
