Java 项目实战: 外卖平台-删除分类的关联校验与自定义业务异常

删除分类、关联校验与自定义业务异常 CustomException

纲要

  • 业务约束 :分类关联了菜品或套餐时不允许删除,必须先解除关联
  • 演进路径 :先用 removeById 简单实现 → 测试发现问题 → 扩展自定义 remove(Long id) → 注入 DishService/SetmealService 做关联校验
  • 基础准备 :新增 DishSetmeal 两个实体及其 Mapper/Service/ServiceImpl
  • 关联查询LambdaQueryWrapper 构造 WHERE category_id = ?,调用 count() 统计关联数量
  • 异常设计 :自定义 CustomException extends RuntimeException,在 GlobalExceptionHandler 中新增处理分支
  • 请求细节DELETE /category?id=xxxid 通过 URL 查询参数传递,不需要 @RequestBody
  • 代码缺陷 :原始代码 setmealService.count() 漏传条件构造器,导致套餐表有任意数据时所有分类都无法删除

需求分析:删除不是删一行

「删除」在管理系统里从来不是一个 DELETE 语句那么简单。分类与其他实体存在引用关系
#mermaid-svg-SWFnQ06sAGtaWBIX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SWFnQ06sAGtaWBIX .error-icon{fill:#552222;}#mermaid-svg-SWFnQ06sAGtaWBIX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SWFnQ06sAGtaWBIX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SWFnQ06sAGtaWBIX .marker.cross{stroke:#333333;}#mermaid-svg-SWFnQ06sAGtaWBIX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SWFnQ06sAGtaWBIX p{margin:0;}#mermaid-svg-SWFnQ06sAGtaWBIX .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-SWFnQ06sAGtaWBIX .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-SWFnQ06sAGtaWBIX .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-SWFnQ06sAGtaWBIX .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-SWFnQ06sAGtaWBIX .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-SWFnQ06sAGtaWBIX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SWFnQ06sAGtaWBIX .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-SWFnQ06sAGtaWBIX .node rect,#mermaid-svg-SWFnQ06sAGtaWBIX .node circle,#mermaid-svg-SWFnQ06sAGtaWBIX .node ellipse,#mermaid-svg-SWFnQ06sAGtaWBIX .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SWFnQ06sAGtaWBIX .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-SWFnQ06sAGtaWBIX .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-SWFnQ06sAGtaWBIX .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SWFnQ06sAGtaWBIX .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-SWFnQ06sAGtaWBIX .edgeLabel .label text{fill:#333;}#mermaid-svg-SWFnQ06sAGtaWBIX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} category_id 引用
category_id 引用
dish_id 引用
setmeal_id 引用
CATEGORY
bigint
id
PK
int
type
varchar
name
UK
int
sort
DISH
bigint
id
PK
bigint
category_id
FK
varchar
name
UK
decimal
price
SETMEAL
bigint
id
PK
bigint
category_id
FK
varchar
name
UK
decimal
price
DISH_FLAVOR
SETMEAL_DISH

如果直接删除「精品热菜」这个分类,而它下面挂着 10 个菜品,会发生什么?

  • 数据库层面:category 表没有外键约束,删除会成功
  • 业务层面:那 10 个菜品的 category_id 指向一个不存在的分类,变成孤儿数据
  • 展示层面:移动端按分类查菜品时,JOIN 或二次查询找不到对应分类,页面报错或空白

所以必须在删除前拦截。

为什么不用数据库外键

方案 优点 缺点
数据库外键 FOREIGN KEY ... ON DELETE RESTRICT 数据一致性最强 影响写入性能;级联操作不透明;分库分表时无法使用
应用层校验 可返回友好文案;易扩展(如「是否强制删除」) 依赖开发者自觉,绕过 Service 直连数据库时失效
逻辑删除 is_deleted 数据可恢复 需要所有查询都带上条件

外卖项目采用应用层校验 + 部分表逻辑删除 的组合:分类做应用层校验,菜品与套餐用 is_deleted 字段。

第一版:简单实现

Controller 方法

java 复制代码
/**
 * 根据id删除分类
 * @param id
 * @return
 */
@DeleteMapping
public R<String> delete(Long id) {
    log.info("删除分类,id为:{}", id);
    categoryService.removeById(id);
    return R.success("分类信息删除成功");
}

参数绑定方式

请求是 DELETE /category?id=1397844263642378242idURL 查询串里,所以方法形参 Long id 直接就能绑定,由 Spring MVCRequestParamMethodArgumentResolver 处理。

对比一下三种常见的参数位置:

参数位置 示例 需要的注解
URL 查询串 /category?id=1 无(同名即可)或 @RequestParam
URL 路径 /category/1 @PathVariable
请求体 JSON {"id":1} @RequestBody

前端的请求构建:

js 复制代码
// 删除分类
function deleteCategory(id) {
  return $axios({
    url: '/category',
    method: 'delete',
    params: { id }
  })
}

axiosparamsDELETE 请求下同样会拼到 URL 后面。

测试暴露的问题

启动后随便删一个分类,控制台:

txt 复制代码
==>  Preparing: DELETE FROM category WHERE id=?
==> Parameters: 1397844263642378242(Long)
<==    Updates: 1

删除成功。但数据库里该分类下的 10 个菜品现在成了孤儿。这就是要「完善」的原因。

基础类准备

要校验关联关系,必须能查询 dish 表和 setmeal 表,因此需要先把这两个实体的四层结构建出来。

目录结构

txt 复制代码
src/main/java/com/itheima/reggie/
├── entity/
│   ├── Category.java
│   ├── Dish.java                    ← 本轮新增
│   └── Setmeal.java                 ← 本轮新增
├── mapper/
│   ├── CategoryMapper.java
│   ├── DishMapper.java              ← 本轮新增
│   └── SetmealMapper.java           ← 本轮新增
├── service/
│   ├── CategoryService.java
│   ├── DishService.java             ← 本轮新增
│   ├── SetmealService.java          ← 本轮新增
│   └── impl/
│       ├── CategoryServiceImpl.java
│       ├── DishServiceImpl.java     ← 本轮新增
│       └── SetmealServiceImpl.java  ← 本轮新增
└── common/
    ├── CustomException.java         ← 本轮新增
    └── GlobalExceptionHandler.java  ← 本轮新增处理分支

DishMapper / SetmealMapper

java 复制代码
package com.itheima.reggie.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Dish;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface DishMapper extends BaseMapper<Dish> {
}
java 复制代码
package com.itheima.reggie.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Setmeal;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface SetmealMapper extends BaseMapper<Setmeal> {
}

DishService / SetmealService

java 复制代码
package com.itheima.reggie.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.Dish;

public interface DishService extends IService<Dish> {
}
java 复制代码
package com.itheima.reggie.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.Setmeal;

public interface SetmealService extends IService<Setmeal> {
}

实现类

java 复制代码
package com.itheima.reggie.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.Dish;
import com.itheima.reggie.mapper.DishMapper;
import com.itheima.reggie.service.DishService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
}
java 复制代码
package com.itheima.reggie.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.Setmeal;
import com.itheima.reggie.mapper.SetmealMapper;
import com.itheima.reggie.service.SetmealService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> implements SetmealService {
}

DishSetmeal 实体

java 复制代码
package com.itheima.reggie.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 菜品
 */
@Data
public class Dish implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    //菜品名称
    private String name;

    //菜品分类id
    private Long categoryId;

    //菜品价格
    private BigDecimal price;

    //商品码
    private String code;

    //图片
    private String image;

    //描述信息
    private String description;

    //0 停售 1 起售
    private Integer status;

    //顺序
    private Integer sort;

    @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;
}
java 复制代码
package com.itheima.reggie.entity;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 套餐
 */
@Data
public class Setmeal implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    //分类id
    private Long categoryId;

    //套餐名称
    private String name;

    //套餐价格
    private BigDecimal price;

    //状态 0:停用 1:启用
    private Integer status;

    //编码
    private String code;

    //描述信息
    private String description;

    //图片
    private String image;

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

两个实体都有 categoryId 字段,这是关联校验的桥梁。

第二版:自定义 remove 方法做关联校验

扩展 Service 接口

IService 提供的 removeById 无法满足业务需求,需要在 CategoryService 中声明自己的方法:

java 复制代码
public interface CategoryService extends IService<Category> {

    public void remove(Long id);
}

注意方法名不要与 IService 已有的 removeByIdremove(Wrapper) 冲突。remove(Long id) 是一个全新的重载。

实现类

java 复制代码
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper,Category> implements CategoryService{

    @Autowired
    private DishService dishService;

    @Autowired
    private SetmealService setmealService;

    /**
     * 根据id删除分类,删除之前需要进行判断
     * @param id
     */
    @Override
    public void remove(Long id) {
        LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
        //添加查询条件,根据分类id进行查询
        dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);
        int count1 = dishService.count(dishLambdaQueryWrapper);

        //查询当前分类是否关联了菜品,如果已经关联,抛出一个业务异常
        if(count1 > 0){
            throw new CustomException("当前分类下关联了菜品,不能删除");
        }

        //查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
        LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
        int count2 = setmealService.count(setmealLambdaQueryWrapper);
        if(count2 > 0){
            throw new CustomException("当前分类下关联了套餐,不能删除");
        }

        //正常删除分类
        super.removeById(id);
    }
}

执行逻辑

#mermaid-svg-MEbAW6J5l4V5CMAX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MEbAW6J5l4V5CMAX .error-icon{fill:#552222;}#mermaid-svg-MEbAW6J5l4V5CMAX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MEbAW6J5l4V5CMAX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MEbAW6J5l4V5CMAX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MEbAW6J5l4V5CMAX .marker.cross{stroke:#333333;}#mermaid-svg-MEbAW6J5l4V5CMAX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MEbAW6J5l4V5CMAX p{margin:0;}#mermaid-svg-MEbAW6J5l4V5CMAX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster-label text{fill:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster-label span{color:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster-label span p{background-color:transparent;}#mermaid-svg-MEbAW6J5l4V5CMAX .label text,#mermaid-svg-MEbAW6J5l4V5CMAX span{fill:#333;color:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX .node rect,#mermaid-svg-MEbAW6J5l4V5CMAX .node circle,#mermaid-svg-MEbAW6J5l4V5CMAX .node ellipse,#mermaid-svg-MEbAW6J5l4V5CMAX .node polygon,#mermaid-svg-MEbAW6J5l4V5CMAX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MEbAW6J5l4V5CMAX .rough-node .label text,#mermaid-svg-MEbAW6J5l4V5CMAX .node .label text,#mermaid-svg-MEbAW6J5l4V5CMAX .image-shape .label,#mermaid-svg-MEbAW6J5l4V5CMAX .icon-shape .label{text-anchor:middle;}#mermaid-svg-MEbAW6J5l4V5CMAX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MEbAW6J5l4V5CMAX .rough-node .label,#mermaid-svg-MEbAW6J5l4V5CMAX .node .label,#mermaid-svg-MEbAW6J5l4V5CMAX .image-shape .label,#mermaid-svg-MEbAW6J5l4V5CMAX .icon-shape .label{text-align:center;}#mermaid-svg-MEbAW6J5l4V5CMAX .node.clickable{cursor:pointer;}#mermaid-svg-MEbAW6J5l4V5CMAX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MEbAW6J5l4V5CMAX .arrowheadPath{fill:#333333;}#mermaid-svg-MEbAW6J5l4V5CMAX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MEbAW6J5l4V5CMAX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MEbAW6J5l4V5CMAX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MEbAW6J5l4V5CMAX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MEbAW6J5l4V5CMAX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MEbAW6J5l4V5CMAX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster text{fill:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX .cluster span{color:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MEbAW6J5l4V5CMAX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MEbAW6J5l4V5CMAX rect.text{fill:none;stroke-width:0;}#mermaid-svg-MEbAW6J5l4V5CMAX .icon-shape,#mermaid-svg-MEbAW6J5l4V5CMAX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MEbAW6J5l4V5CMAX .icon-shape p,#mermaid-svg-MEbAW6J5l4V5CMAX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MEbAW6J5l4V5CMAX .icon-shape .label rect,#mermaid-svg-MEbAW6J5l4V5CMAX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MEbAW6J5l4V5CMAX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MEbAW6J5l4V5CMAX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MEbAW6J5l4V5CMAX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



remove(Long id)
构造 Dish 的条件构造器
WHERE category_id = id
count1 = dishService.count(wrapper)
count1 > 0 ?
throw CustomException 当前分类下关联了菜品,不能删除
构造 Setmeal 的条件构造器
count2 = setmealService.count(wrapper)
count2 > 0 ?
throw CustomException 当前分类下关联了套餐,不能删除
super.removeById id
删除成功

先写期望 SQL,再组装条件

课程里强调的开发方法值得借鉴:在写 Java 代码之前,先在数据库客户端里把期望的 SQL 写出来。

期望的查询:

sql 复制代码
SELECT COUNT(*) FROM dish WHERE category_id = 1397844263642378242;

结果 10,说明该分类下有 10 个菜品。

然后「照着 SQL 组装条件」:

SQL 片段 对应的 Java 代码
FROM dish new LambdaQueryWrapper<Dish>()
WHERE category_id .eq(Dish::getCategoryId, ...)
= 1397844263642378242 .eq(Dish::getCategoryId, id)
COUNT(*) dishService.count(wrapper)

这个方法能显著降低写错条件的概率,尤其是复杂查询场景。

super.removeById(id) 的意义

CategoryServiceImpl 继承了 ServiceImpl,自身也有一个 removeById 方法(来自 IService 的默认实现)。这里显式写 super.removeById(id) 是为了:

  • 明确表达「调用父类提供的通用实现」
  • 避免与自定义的 remove 方法产生语义混淆
  • 如果子类将来重写 removeByIdsuper. 依然指向原始实现

自定义业务异常

为什么需要 CustomException

RuntimeException 太宽泛,IllegalArgumentException 语义不符。业务异常需要承载两个职责:

  • 中断流程:抛出后方法不再继续执行,事务回滚
  • 携带文案:异常信息要原样展示给用户

CustomException

java 复制代码
package com.itheima.reggie.common;

/**
 * 自定义业务异常类
 */
public class CustomException extends RuntimeException {
    public CustomException(String message){
        super(message);
    }
}

为什么继承 RuntimeException 而不是 Exception

父类 是否受检 方法签名要求 Spring 事务回滚
Exception 受检 必须 throws 声明 默认不回滚
RuntimeException 非受检 无需声明 默认回滚

RuntimeException 的两个理由:

  • 不需要在每一层方法签名上写 throws,接口更干净
  • Spring@Transactional 默认只对 RuntimeExceptionError 回滚。如果用受检异常,还得写 @Transactional(rollbackFor = Exception.class)

GlobalExceptionHandler 增加分支

java 复制代码
package com.itheima.reggie.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局异常处理
 */
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 异常处理方法:处理唯一约束冲突
     * @return
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
        log.error(ex.getMessage());

        if(ex.getMessage().contains("Duplicate entry")){
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return R.error(msg);
        }

        return R.error("未知错误");
    }

    /**
     * 异常处理方法:处理自定义业务异常
     * @return
     */
    @ExceptionHandler(CustomException.class)
    public R<String> exceptionHandler(CustomException ex){
        log.error(ex.getMessage());

        return R.error(ex.getMessage());
    }
}

异常匹配机制

Spring MVCExceptionHandlerExceptionResolver 在匹配异常处理器时,遵循最精确匹配原则
#mermaid-svg-qdX29HlAeOSfBXQV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qdX29HlAeOSfBXQV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qdX29HlAeOSfBXQV .error-icon{fill:#552222;}#mermaid-svg-qdX29HlAeOSfBXQV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qdX29HlAeOSfBXQV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qdX29HlAeOSfBXQV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qdX29HlAeOSfBXQV .marker.cross{stroke:#333333;}#mermaid-svg-qdX29HlAeOSfBXQV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qdX29HlAeOSfBXQV p{margin:0;}#mermaid-svg-qdX29HlAeOSfBXQV .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qdX29HlAeOSfBXQV .cluster-label text{fill:#333;}#mermaid-svg-qdX29HlAeOSfBXQV .cluster-label span{color:#333;}#mermaid-svg-qdX29HlAeOSfBXQV .cluster-label span p{background-color:transparent;}#mermaid-svg-qdX29HlAeOSfBXQV .label text,#mermaid-svg-qdX29HlAeOSfBXQV span{fill:#333;color:#333;}#mermaid-svg-qdX29HlAeOSfBXQV .node rect,#mermaid-svg-qdX29HlAeOSfBXQV .node circle,#mermaid-svg-qdX29HlAeOSfBXQV .node ellipse,#mermaid-svg-qdX29HlAeOSfBXQV .node polygon,#mermaid-svg-qdX29HlAeOSfBXQV .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qdX29HlAeOSfBXQV .rough-node .label text,#mermaid-svg-qdX29HlAeOSfBXQV .node .label text,#mermaid-svg-qdX29HlAeOSfBXQV .image-shape .label,#mermaid-svg-qdX29HlAeOSfBXQV .icon-shape .label{text-anchor:middle;}#mermaid-svg-qdX29HlAeOSfBXQV .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qdX29HlAeOSfBXQV .rough-node .label,#mermaid-svg-qdX29HlAeOSfBXQV .node .label,#mermaid-svg-qdX29HlAeOSfBXQV .image-shape .label,#mermaid-svg-qdX29HlAeOSfBXQV .icon-shape .label{text-align:center;}#mermaid-svg-qdX29HlAeOSfBXQV .node.clickable{cursor:pointer;}#mermaid-svg-qdX29HlAeOSfBXQV .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qdX29HlAeOSfBXQV .arrowheadPath{fill:#333333;}#mermaid-svg-qdX29HlAeOSfBXQV .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qdX29HlAeOSfBXQV .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qdX29HlAeOSfBXQV .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qdX29HlAeOSfBXQV .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qdX29HlAeOSfBXQV .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qdX29HlAeOSfBXQV .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qdX29HlAeOSfBXQV .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qdX29HlAeOSfBXQV .cluster text{fill:#333;}#mermaid-svg-qdX29HlAeOSfBXQV .cluster span{color:#333;}#mermaid-svg-qdX29HlAeOSfBXQV div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qdX29HlAeOSfBXQV .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qdX29HlAeOSfBXQV rect.text{fill:none;stroke-width:0;}#mermaid-svg-qdX29HlAeOSfBXQV .icon-shape,#mermaid-svg-qdX29HlAeOSfBXQV .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qdX29HlAeOSfBXQV .icon-shape p,#mermaid-svg-qdX29HlAeOSfBXQV .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qdX29HlAeOSfBXQV .icon-shape .label rect,#mermaid-svg-qdX29HlAeOSfBXQV .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qdX29HlAeOSfBXQV .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qdX29HlAeOSfBXQV .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qdX29HlAeOSfBXQV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



Controller 抛出异常
DispatcherServlet 捕获
ExceptionHandlerExceptionResolver 处理
收集所有 @ExceptionHandler 方法
按异常类型的继承深度排序
选择类型距离最近的一个
是 CustomException?
exceptionHandler(CustomException)
是 SQLIntegrityConstraintViolationException?
exceptionHandler(SQLIntegrityConstraintViolationException)
继续向上抛出,最终 500

当抛出 CustomException 时,虽然它也 is-a RuntimeException,但因为有精确匹配 CustomException.class 的处理器,会优先选它。

@ControllerAdvice(annotations = ...) 的作用范围

java 复制代码
@ControllerAdvice(annotations = {RestController.class, Controller.class})

限定只对被 @RestController@Controller 标注的类生效。这样对静态资源请求、Filter 中抛出的异常不会误处理。

代码缺陷:count() 漏传条件

问题代码

仓库 day06 中的原始实现:

java 复制代码
LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
int count2 = setmealService.count();        // ← 这里漏了参数

后果

count() 的无参重载统计的是全表行数 。只要 setmeal 表有任何一条数据,count2 > 0 恒成立,于是:

  • 所有分类都无法删除,即使该分类下没有任何套餐
  • 报错信息是「当前分类下关联了套餐,不能删除」,极具误导性

正确的 SQL 对比

sql 复制代码
-- 期望
SELECT COUNT(*) FROM setmeal WHERE category_id = 1397844263642378242;

-- 实际发出的
SELECT COUNT(*) FROM setmeal;

为什么容易被忽略

因为测试时通常先测「关联了菜品」的分支------第一个 if 就抛异常了,第二个分支根本走不到。只有新建一个全新的、什么都不关联的分类去测,才会发现问题。这是一个典型的测试覆盖不全导致的缺陷。

修复

java 复制代码
int count2 = setmealService.count(setmealLambdaQueryWrapper);

进一步的健壮性建议

生产环境还可以加一层主动校验,避免依赖 Nullableid

java 复制代码
@Override
public void remove(Long id) {
    if (id == null) {
        throw new CustomException("分类id不能为空");
    }
    Category category = super.getById(id);
    if (category == null) {
        throw new CustomException("分类不存在");
    }
    // ... 关联校验
}

功能测试

场景一:删除已关联菜品的分类

remove 方法首行打断点,Debug 模式启动。选择一个已有菜品的分类(如「湘菜」),点删除 → 确定。

程序停在断点,id 的值为 1397844263642378242。放行后控制台:

txt 复制代码
==>  Preparing: SELECT COUNT(*) FROM dish WHERE (category_id = ?)
==> Parameters: 1397844263642378242(Long)
<==      Total: 1

ERROR ... 当前分类下关联了菜品,不能删除

响应:

json 复制代码
{"code":0,"msg":"当前分类下关联了菜品,不能删除","data":null,"map":{}}

页面弹出红色提示条,文案与 CustomException 中的一致。

场景二:删除无关联的分类

新建一个测试分类「测试分类」,不关联任何菜品套餐,然后删除:

txt 复制代码
==>  Preparing: SELECT COUNT(*) FROM dish WHERE (category_id = ?)
==> Parameters: 1567654321123456789(Long)
<==      Total: 1   →  count1 = 0

==>  Preparing: SELECT COUNT(*) FROM setmeal WHERE (category_id = ?)
==> Parameters: 1567654321123456789(Long)
<==      Total: 1   →  count2 = 0

==>  Preparing: DELETE FROM category WHERE id=?
==> Parameters: 1567654321123456789(Long)
<==    Updates: 1

响应:

json 复制代码
{"code":1,"data":"分类信息删除成功","msg":null,"map":{}}

注意上面 Total: 1 指的是 COUNT 查询返回了 1 行(值为 0),不是查到 1 条关联数据。MyBatisTotal 表示结果集行数。

场景三:套餐校验

由于 day06 阶段还没做套餐管理功能,setmeal 表是空的,count2 永远为 0,第二个分支不易触发。可以在数据库手动插一条套餐数据来验证:

sql 复制代码
INSERT INTO setmeal (id, category_id, name, price, status, code, description, image,
                     create_time, update_time, create_user, update_user)
VALUES (1567654321123456790, 1397844263642378242, '测试套餐', 68.00, 1, 'SM001',
        '测试用', 'test.jpg', NOW(), NOW(), 1, 1);

再删除「湘菜」分类,此时 count2 = 1,应抛出「当前分类下关联了套餐,不能删除」。

完整可运行代码

CategoryController.java

java 复制代码
package com.itheima.reggie.controller;

import com.itheima.reggie.common.R;
import com.itheima.reggie.service.CategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * 分类管理
 */
@RestController
@RequestMapping("/category")
@Slf4j
public class CategoryController {
    @Autowired
    private CategoryService categoryService;

    /**
     * 根据id删除分类
     * @param id
     * @return
     */
    @DeleteMapping
    public R<String> delete(Long id){
        log.info("删除分类,id为:{}",id);

        categoryService.remove(id);

        return R.success("分类信息删除成功");
    }
}

CategoryService.java

java 复制代码
package com.itheima.reggie.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.Category;

public interface CategoryService extends IService<Category> {

    public void remove(Long id);

}

CategoryServiceImpl.java(修正版)

java 复制代码
package com.itheima.reggie.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.common.CustomException;
import com.itheima.reggie.entity.Category;
import com.itheima.reggie.entity.Dish;
import com.itheima.reggie.entity.Setmeal;
import com.itheima.reggie.mapper.CategoryMapper;
import com.itheima.reggie.service.CategoryService;
import com.itheima.reggie.service.DishService;
import com.itheima.reggie.service.SetmealService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper,Category> implements CategoryService{

    @Autowired
    private DishService dishService;

    @Autowired
    private SetmealService setmealService;

    /**
     * 根据id删除分类,删除之前需要进行判断
     * @param id
     */
    @Override
    public void remove(Long id) {
        LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
        //添加查询条件,根据分类id进行查询
        dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);
        int count1 = dishService.count(dishLambdaQueryWrapper);

        //查询当前分类是否关联了菜品,如果已经关联,抛出一个业务异常
        if(count1 > 0){
            //已经关联菜品,抛出一个业务异常
            throw new CustomException("当前分类下关联了菜品,不能删除");
        }

        //查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
        LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
        //添加查询条件,根据分类id进行查询
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
        int count2 = setmealService.count(setmealLambdaQueryWrapper);
        if(count2 > 0){
            //已经关联套餐,抛出一个业务异常
            throw new CustomException("当前分类下关联了套餐,不能删除");
        }

        //正常删除分类
        super.removeById(id);
    }
}

CustomException.java

java 复制代码
package com.itheima.reggie.common;

/**
 * 自定义业务异常类
 */
public class CustomException extends RuntimeException {
    public CustomException(String message){
        super(message);
    }
}

GlobalExceptionHandler.java

注意 @ControllerAdvice 的包路径是 org.springframework.stereotype.ControllerAdvice,不要误引成 org.springframework.web.bind.annotation 包下的类。

java 复制代码
package com.itheima.reggie.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局异常处理
 */
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 异常处理方法:处理唯一约束冲突
     * @return
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public R<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){
        log.error(ex.getMessage());

        if(ex.getMessage().contains("Duplicate entry")){
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return R.error(msg);
        }

        return R.error("未知错误");
    }

    /**
     * 异常处理方法:处理自定义业务异常
     * @return
     */
    @ExceptionHandler(CustomException.class)
    public R<String> exceptionHandler(CustomException ex){
        log.error(ex.getMessage());

        return R.error(ex.getMessage());
    }
}

前端 category.js

js 复制代码
// 删除当前分类
function deleteCategory(id) {
  return $axios({
    url: '/category',
    method: 'delete',
    params: { id }
  })
}

前端删除按钮逻辑

js 复制代码
deleteHandle(id) {
  this.$confirm('确认删除该分类吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    deleteCategory(id).then(res => {
      if (res.code === 1) {
        this.$message.success('分类删除成功!')
        this.handleQuery()
      } else {
        this.$message.error(res.msg || '操作失败')
      }
    })
  })
}

注意 else 分支:res.code !== 1 时要把 res.msg 显示出来,否则用户只会看到「操作失败」,看不到「当前分类下关联了菜品」这个关键提示。

API 速览

API 所属框架 作用
@DeleteMapping Spring MVC 限定 DELETE 方法
IService.removeById(Serializable) MyBatis-Plus 根据主键删除单条记录
IService.count() MyBatis-Plus 统计全表行数(无参重载)
IService.count(Wrapper) MyBatis-Plus 按条件统计行数
LambdaQueryWrapper.eq(SFunction, Object) MyBatis-Plus 添加等值条件,默认无条件拼接
LambdaQueryWrapper.eq(boolean, SFunction, Object) MyBatis-Plus 第一个参数为 true 时才拼接该条件
RuntimeException(String) JDK 运行时异常基类,携带异常信息
@ControllerAdvice Spring MVC 全局控制器增强,用于集中异常处理
@ExceptionHandler Spring MVC 声明处理特定异常类型的方法
@ResponseBody Spring MVC 返回值直接序列化为 JSON
R.error(String) 本项目 返回失败响应,code = 0msg 为提示文案

官方文档

总结

这篇文章把「删除分类」这个看似简单的功能,从第一版直接 removeById 删掉,到发现问题、再一步步完善成带关联校验的完整方案,整个过程走了一遍。

核心就三件事:

  • 删除前先查关联 :用 LambdaQueryWrapper 分别查 dishsetmeal 表,count() 大于 0 就说明有东西挂着,不能删。
  • 自定义异常CustomException extends RuntimeException,配合 GlobalExceptionHandler 把「当前分类下关联了菜品/套餐,不能删除」这种文案原样抛给前端,用户一看就懂。
  • 一个容易踩的坑setmealService.count() 漏传条件构造器,导致只要套餐表有数据,所有分类都删不掉。这种 bug 藏得深,因为测试时往往先测「关联了菜品」的分支,第一个 if 就抛异常了,第二个分支根本走不到。

最后提醒一句:写这类关联校验,先想清楚期望的 SQL 长什么样,再照着组装条件,能少踩很多坑。

相关推荐
zly35001 小时前
VMware Converter Standalone 物理机转化为虚拟机后源1硬盘变成了2个硬盘(2个硬盘文件)虚拟机无法启动。
linux·运维·服务器
hai_android1 小时前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin
MayBaymax1 小时前
MongoDB 索引与事务
java·数据库·mongodb
学习中的码虫1 小时前
网络编程5
服务器·网络
vx-程序开发1 小时前
【计算机毕设】django校园跑腿服务系统83141
java·数据库·vue.js·spring boot·spring·elasticsearch·django
勇气要爆发2 小时前
006.注解
java·注解
liuchangng2 小时前
Jev 模型研究:从生成式大模型到决策式模型——System One、RLCD 校准与采用边界
java·javascript·人工智能·python·深度学习
GPU实战笔记2 小时前
本地跑不动 llama.cpp,临时租云 GPU 怎么搭?从启动服务到环境复用
java·服务器·网络·人工智能·深度学习·llama
撸串-研究生2 小时前
SSM + Vue 线上作业批改系统:在线答题、文件提交与教师批改闭环90608
java·ssm·在线答题·作业批改·文件提交