1. 项目概述
"苍穹外卖-day03-菜品与套餐删除模块"是外卖系统开发中一个关键的功能模块,主要解决菜品和套餐在删除操作时的业务逻辑校验问题。在实际开发中,删除操作往往不是简单的数据库记录删除,而是需要考虑各种业务关联和约束条件。
这个模块的核心在于"两大校验"——即删除前的业务规则校验和数据完整性校验。前者确保删除操作符合业务规则(如已上架的菜品不能直接删除),后者保证删除不会破坏数据关联(如被套餐包含的菜品不能被单独删除)。
2. 核心需求解析
2.1 业务场景分析
在外卖系统中,菜品和套餐的删除需要特别谨慎处理。以下是几个典型场景:
菜品删除场景:
- 普通菜品未被任何套餐引用时可直接删除
- 菜品已被套餐包含时需先解除关联
- 正在促销或特殊活动中的菜品需先下架
套餐删除场景:
- 套餐未被任何订单引用时可直接删除
- 套餐正在被用户浏览或收藏时需特殊处理
- 套餐处于活动期间需先终止活动
2.2 校验规则设计
基于上述场景,我们需要设计两类校验规则:
前置条件校验:
// 示例:菜品删除前置校验 public void validateBeforeDelete(Dish dish) { // 校验1:菜品是否已上架 if (dish.getStatus() == StatusEnum.ON_SHELF.getCode()) { throw new BusinessException("已上架菜品需先下架"); } // 校验2:菜品是否被套餐引用 int usedCount = setmealDishMapper.countByDishId(dish.getId()); if (usedCount > 0) { throw new BusinessException("菜品被套餐引用,无法删除"); } }事务完整性校验:
@Transactional public void deleteDishWithValidation(Long dishId) { Dish dish = dishMapper.selectById(dishId); validateBeforeDelete(dish); // 执行删除操作 dishMapper.deleteById(dishId); dishFlavorMapper.deleteByDishId(dishId); // 同步删除关联的口味数据 }
3. 技术实现细节
3.1 校验逻辑实现
3.1.1 状态校验
对于菜品和套餐的状态校验,我们采用状态模式进行管理:
public interface StatusValidator { void validate(StatusAware entity); } @Component public class OnShelfValidator implements StatusValidator { @Override public void validate(StatusAware entity) { if (entity.getStatus() == StatusEnum.ON_SHELF.getCode()) { throw new BusinessException("当前状态不允许删除"); } } }3.1.2 关联关系校验
关联关系校验需要考虑多种情况:
- 菜品被套餐引用
- 套餐被订单引用
- 历史数据关联
我们使用SQL查询结合缓存机制优化校验性能:
<!-- 查询菜品被套餐引用次数 --> <select id="countByDishId" resultType="int"> SELECT COUNT(*) FROM setmeal_dish WHERE dish_id = #{dishId} </select>3.2 事务处理机制
删除操作通常需要保证数据一致性,我们采用Spring事务管理:
@Service @RequiredArgsConstructor public class DishServiceImpl implements DishService { private final DishMapper dishMapper; private final SetmealDishMapper setmealDishMapper; @Transactional(rollbackFor = Exception.class) public void deleteDish(Long id) { // 校验逻辑 validateBeforeDelete(id); // 删除主表数据 dishMapper.deleteById(id); // 删除关联表数据 dishFlavorMapper.deleteByDishId(id); setmealDishMapper.deleteByDishId(id); } }4. 异常处理与日志
4.1 自定义异常体系
针对校验失败情况,设计专门的异常类:
public class ValidationException extends RuntimeException { private final ErrorCode errorCode; public ValidationException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } // getter方法 }4.2 日志记录策略
采用AOP实现操作日志记录:
@Aspect @Component @Slf4j public class OperationLogAspect { @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { long startTime = System.currentTimeMillis(); Object result; try { result = joinPoint.proceed(); long endTime = System.currentTimeMillis(); log.info("操作成功 | 耗时:{}ms | 操作:{}", (endTime - startTime), operationLog.value()); return result; } catch (Exception e) { log.error("操作失败 | 原因:{}", e.getMessage()); throw e; } } }5. 性能优化方案
5.1 缓存策略
对于频繁校验的数据,引入缓存机制:
@Service @RequiredArgsConstructor public class DishServiceImpl implements DishService { private final RedisTemplate<String, Object> redisTemplate; private static final String DISH_USAGE_KEY = "dish:usage:"; public boolean isDishUsed(Long dishId) { // 先查缓存 Boolean used = (Boolean) redisTemplate.opsForValue().get(DISH_USAGE_KEY + dishId); if (used != null) { return used; } // 缓存未命中查数据库 int count = setmealDishMapper.countByDishId(dishId); boolean result = count > 0; // 写入缓存,设置5分钟过期 redisTemplate.opsForValue().set( DISH_USAGE_KEY + dishId, result, 5, TimeUnit.MINUTES); return result; } }5.2 批量删除优化
对于批量删除操作,采用批量处理方式:
@Transactional public void batchDeleteDishes(List<Long> ids) { // 前置校验 ids.forEach(this::validateBeforeDelete); // 批量删除 dishMapper.deleteBatchIds(ids); // 批量删除关联数据 dishFlavorMapper.deleteByDishIds(ids); setmealDishMapper.deleteByDishIds(ids); }6. 安全考量
6.1 权限校验
在删除操作前增加权限校验:
@PreAuthorize("hasAuthority('dish:delete')") public void deleteDish(Long id) { // 删除逻辑 }6.2 防重复提交
采用Token机制防止重复提交:
@PostMapping("/delete") public Result deleteDish(@RequestParam Long id, @RequestHeader("X-Request-Token") String token) { if (!tokenService.validateToken(token)) { throw new BusinessException("请勿重复提交"); } dishService.deleteDish(id); return Result.success(); }7. 测试策略
7.1 单元测试用例
@SpringBootTest class DishServiceTest { @Autowired private DishService dishService; @Test void testDeleteDishWithValidation() { // 准备测试数据 Dish dish = new Dish(); dish.setStatus(StatusEnum.ON_SHELF.getCode()); dishMapper.insert(dish); // 验证异常抛出 assertThrows(BusinessException.class, () -> { dishService.deleteDish(dish.getId()); }); } }7.2 集成测试场景
- 测试删除被套餐引用的菜品
- 测试删除已上架的菜品
- 测试批量删除含无效ID的情况
- 测试无权限用户尝试删除
8. 前端交互设计
8.1 删除确认流程
前端实现二次确认和状态提示:
async function handleDelete(dishId) { try { const confirmed = await confirm('确定要删除该菜品吗?'); if (!confirmed) return; const { data } = await deleteDish(dishId); if (data.code === 200) { message.success('删除成功'); reloadData(); } else { message.error(data.msg); } } catch (error) { message.error(error.message); } }8.2 错误提示优化
根据后端返回的错误码显示友好提示:
const errorMessages = { 'DIS_USED_IN_SETMEAL': '该菜品已被套餐使用,无法删除', 'DISH_ON_SHELF': '菜品已上架,请先下架' }; function showError(errorCode) { const message = errorMessages[errorCode] || '操作失败,请稍后重试'; alert(message); }9. 扩展思考
9.1 软删除实现方案
考虑业务需求,可以采用逻辑删除替代物理删除:
@TableLogic private Integer deleted; public void logicDelete(Long id) { Dish dish = new Dish(); dish.setId(id); dish.setDeleted(1); dishMapper.updateById(dish); }9.2 操作记录审计
记录删除操作的重要信息:
@EntityListeners(AuditingEntityListener.class) public class Dish { // ... @CreatedBy private String createdBy; @LastModifiedBy private String modifiedBy; }10. 实际开发中的经验总结
- 校验顺序很重要:应该先校验状态再校验关联关系,因为状态校验通常更快
- 异常信息要明确:错误提示应该让用户知道具体原因和解决方法
- 事务边界要合理:不要把整个业务流程都放在一个大事务中
- 考虑并发场景:高并发时校验结果可能会变化,需要适当加锁
- 日志要详细:删除操作的日志应该记录操作人、时间、操作对象等关键信息
在实现删除功能时,我曾经遇到过因为忽略缓存一致性导致校验失效的问题。具体场景是:管理员A查询菜品未被引用后准备删除,同时管理员B创建了包含该菜品的新套餐。解决方案是采用乐观锁机制:
@Transactional public void deleteDishWithVersion(Long id, Integer version) { Dish dish = dishMapper.selectById(id); if (dish.getVersion() != version) { throw new OptimisticLockException("数据已被修改,请刷新后重试"); } // 执行删除操作 }