1. 项目背景与需求分析
在餐饮外卖系统的开发中,菜品管理模块是最核心的功能之一。这个模块需要处理菜品的全生命周期管理,包括新增、查询、修改、删除以及上下架操作。同时,系统还需要记录每个操作的时间、操作人等信息,用于后续的审计和追踪。
我最近参与开发了一个名为"苍穹外卖"的项目,负责实现了菜品管理模块的几个关键功能。这些功能看似基础,但在实际开发过程中遇到了不少值得分享的技术细节和解决方案。特别是公共字段自动填充这个功能,它看似简单,却能显著提升开发效率和代码质量。
2. 公共字段自动填充实现
2.1 为什么需要公共字段自动填充
在任何业务系统中,几乎每个数据表都会有create_time、update_time、create_user、update_user这样的公共字段。如果每次操作都手动设置这些字段,不仅代码冗余,还容易遗漏。更糟糕的是,如果业务逻辑变更需要修改这些字段的处理方式,就需要在所有相关的地方进行修改。
在苍穹外卖项目中,我们使用MyBatis-Plus提供的MetaObjectHandler接口实现了公共字段的自动填充。这个方案有以下几个优势:
- 统一管理:所有公共字段的处理逻辑集中在一处
- 无侵入性:业务代码不需要关心公共字段的处理
- 可维护性:修改公共字段处理逻辑只需修改一处
2.2 具体实现步骤
首先,我们定义了一个自动填充处理器:
java复制@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "createUser", Long.class, getCurrentUserId());
this.strictInsertFill(metaObject, "updateUser", Long.class, getCurrentUserId());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictUpdateFill(metaObject, "updateUser", Long.class, getCurrentUserId());
}
private Long getCurrentUserId() {
// 从ThreadLocal或SecurityContext中获取当前用户ID
return 1L; // 示例值
}
}
然后,在实体类中使用@TableField注解标记需要自动填充的字段:
java复制public class Dish {
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
// 其他字段...
}
2.3 踩坑经验
在实际开发中,我们遇到了几个需要注意的问题:
-
字段类型匹配:自动填充的值类型必须与实体字段类型完全一致,否则会抛出异常。例如,如果数据库字段是datetime,实体字段也应该是LocalDateTime而不是Date。
-
更新操作的特殊情况:在某些业务场景下,我们可能不希望更新update_time字段。这时可以通过在Mapper方法上添加@SqlParser(filter=true)注解来临时禁用自动填充。
-
分布式环境下的用户ID获取:在微服务架构中,获取当前用户ID可能需要从JWT令牌或ThreadLocal中获取,需要确保填充处理器能够正确获取到上下文信息。
3. 新增菜品功能实现
3.1 业务需求分析
新增菜品功能看似简单,但实际上需要考虑多个业务规则:
- 菜品名称不能重复
- 价格必须大于0
- 分类必须存在
- 需要上传菜品图片
- 口味信息需要与菜品关联保存
3.2 技术实现方案
我们采用分层架构实现新增菜品功能:
- Controller层:接收前端请求,进行参数校验
- Service层:处理业务逻辑,包括菜品信息保存和口味信息保存
- Mapper层:数据库操作
关键代码如下:
java复制@PostMapping
public Result addDish(@RequestBody DishDTO dishDTO) {
log.info("新增菜品:{}", dishDTO);
dishService.saveWithFlavor(dishDTO);
return Result.success();
}
@Service
public class DishServiceImpl implements DishService {
@Autowired
private DishMapper dishMapper;
@Autowired
private DishFlavorMapper dishFlavorMapper;
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
// 保存菜品基本信息
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.insert(dish);
// 保存口味信息
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && !flavors.isEmpty()) {
flavors.forEach(flavor -> {
flavor.setDishId(dish.getId());
dishFlavorMapper.insert(flavor);
});
}
}
}
3.3 性能优化考虑
在处理菜品图片上传时,我们做了以下优化:
- 使用阿里云OSS存储图片,减轻服务器压力
- 对上传图片进行压缩处理,减少存储空间和带宽消耗
- 实现图片上传的异步处理,避免阻塞主业务流程
4. 菜品分页查询功能实现
4.1 分页查询需求分析
菜品分页查询需要支持以下功能:
- 按分类筛选
- 按名称模糊搜索
- 按状态筛选(上架/下架)
- 按创建时间排序
- 分页参数(页码、每页条数)
4.2 MyBatis-Plus分页实现
我们使用MyBatis-Plus的分页插件实现分页查询:
java复制@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
Service层实现:
java复制@Override
public PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {
Page<DishVO> page = new Page<>(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());
// 构造查询条件
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(dishPageQueryDTO.getCategoryId() != null,
Dish::getCategoryId, dishPageQueryDTO.getCategoryId())
.like(StringUtils.isNotBlank(dishPageQueryDTO.getName()),
Dish::getName, dishPageQueryDTO.getName())
.eq(dishPageQueryDTO.getStatus() != null,
Dish::getStatus, dishPageQueryDTO.getStatus())
.orderByDesc(Dish::getUpdateTime);
// 执行分页查询
Page<Dish> dishPage = dishMapper.selectPage(page, queryWrapper);
// 转换为VO对象
List<DishVO> records = dishPage.getRecords().stream()
.map(dish -> {
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish, dishVO);
// 查询分类名称
Category category = categoryMapper.selectById(dish.getCategoryId());
if (category != null) {
dishVO.setCategoryName(category.getName());
}
return dishVO;
}).collect(Collectors.toList());
return new PageResult(dishPage.getTotal(), records);
}
4.3 性能优化实践
在大数据量情况下,分页查询可能会遇到性能问题。我们采取了以下优化措施:
- 避免使用count(1)查询总数,对于不需要精确总数的场景,可以使用缓存的总数
- 对大表添加合适的索引,特别是where条件和order by涉及的字段
- 使用延迟加载关联数据,避免不必要的联表查询
5. 删除菜品功能实现
5.1 业务规则验证
删除菜品不是简单的delete操作,需要验证以下业务规则:
- 菜品必须存在
- 菜品必须处于停售状态
- 菜品不能被任何套餐关联
5.2 实现代码
java复制@Override
@Transactional
public void deleteByIds(List<Long> ids) {
// 验证菜品状态
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.in(Dish::getId, ids)
.eq(Dish::getStatus, StatusConstant.ENABLE);
Long count = dishMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(MessageConstant.DISH_ON_SALE);
}
// 验证是否被套餐关联
List<Long> setmealDishIds = setmealDishMapper.listSetmealIdsByDishIds(ids);
if (setmealDishIds != null && !setmealDishIds.isEmpty()) {
throw new BusinessException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
// 删除菜品
dishMapper.deleteBatchIds(ids);
// 删除关联的口味数据
LambdaQueryWrapper<DishFlavor> flavorQueryWrapper = new LambdaQueryWrapper<>();
flavorQueryWrapper.in(DishFlavor::getDishId, ids);
dishFlavorMapper.delete(flavorQueryWrapper);
}
5.3 删除策略选择
我们考虑过以下几种删除策略:
-
物理删除:直接从数据库删除记录
- 优点:节省存储空间
- 缺点:无法恢复数据,历史记录丢失
-
逻辑删除:使用is_deleted字段标记删除状态
- 优点:可以恢复数据,保留历史记录
- 缺点:需要修改所有查询条件,增加索引大小
经过权衡,我们选择了物理删除方案,因为:
- 菜品数据删除后确实不需要保留
- 系统有完善的备份机制
- 可以简化查询逻辑,提高性能
6. 修改菜品功能实现
6.1 复杂更新场景分析
修改菜品是一个相对复杂的操作,因为:
- 需要更新菜品基本信息
- 可能需要更新菜品图片
- 可能需要更新口味信息(新增、修改、删除)
- 需要保证数据一致性
6.2 事务处理实现
我们使用Spring事务管理来保证数据一致性:
java复制@Override
@Transactional
public void updateWithFlavor(DishDTO dishDTO) {
// 更新菜品基本信息
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
dishMapper.updateById(dish);
// 删除原有口味
LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId, dishDTO.getId());
dishFlavorMapper.delete(queryWrapper);
// 新增口味
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && !flavors.isEmpty()) {
flavors.forEach(flavor -> {
flavor.setDishId(dishDTO.getId());
dishFlavorMapper.insert(flavor);
});
}
}
6.3 并发更新处理
在高并发场景下,更新操作可能会遇到并发问题。我们采用了以下策略:
- 乐观锁:使用version字段控制并发更新
- 更新前检查:确保数据没有被其他用户修改过
- 合理的重试机制:当更新冲突时自动重试
7. 起售停售菜品功能实现
7.1 状态变更的业务影响
菜品状态变更会影响:
- 前端展示:停售菜品不应该显示给用户
- 下单流程:不能下单停售的菜品
- 套餐关联:停售菜品会影响关联套餐的销售
7.2 实现代码
java复制@Override
public void startOrStop(Integer status, Long id) {
Dish dish = Dish.builder()
.id(id)
.status(status)
.updateTime(LocalDateTime.now())
.build();
dishMapper.updateById(dish);
// 如果停售菜品,需要检查关联的套餐
if (status == StatusConstant.DISABLE) {
List<Long> setmealIds = setmealDishMapper.listSetmealIdsByDishIds(Collections.singletonList(id));
if (setmealIds != null && !setmealIds.isEmpty()) {
// 发送消息通知套餐服务
// 这里可以使用消息队列进行解耦
}
}
}
7.3 状态同步问题
当菜品状态变更时,需要确保相关数据的一致性。我们采用了以下方案:
- 使用分布式事务保证核心数据一致性
- 使用消息队列处理非核心数据的最终一致性
- 提供手动同步按钮供运营人员使用
8. 项目总结与优化方向
在实现苍穹外卖菜品管理模块的过程中,我们积累了一些有价值的经验:
- 公共字段自动填充虽然是小功能,但能显著提升开发效率和代码质量
- 复杂业务操作(如修改菜品)需要考虑事务处理和并发控制
- 状态管理需要全面考虑业务影响
未来可能的优化方向包括:
- 引入Elasticsearch提升菜品搜索性能
- 使用Redis缓存热门菜品数据
- 实现菜品变体功能,支持同一菜品不同规格的价格和库存管理
