删除分类、关联校验与自定义业务异常 CustomException
纲要
- 业务约束 :分类关联了菜品或套餐时不允许删除,必须先解除关联
- 演进路径 :先用
removeById简单实现 → 测试发现问题 → 扩展自定义remove(Long id)→ 注入DishService/SetmealService做关联校验 - 基础准备 :新增
Dish、Setmeal两个实体及其Mapper/Service/ServiceImpl - 关联查询 :
LambdaQueryWrapper构造WHERE category_id = ?,调用count()统计关联数量 - 异常设计 :自定义
CustomException extends RuntimeException,在GlobalExceptionHandler中新增处理分支 - 请求细节 :
DELETE /category?id=xxx,id通过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=1397844263642378242。id 在 URL 查询串里,所以方法形参 Long id 直接就能绑定,由 Spring MVC 的 RequestParamMethodArgumentResolver 处理。
对比一下三种常见的参数位置:
| 参数位置 | 示例 | 需要的注解 |
|---|---|---|
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 }
})
}
axios 的 params 在 DELETE 请求下同样会拼到 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 {
}
Dish 与 Setmeal 实体
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已有的removeById、remove(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方法产生语义混淆 - 如果子类将来重写
removeById,super.依然指向原始实现
自定义业务异常
为什么需要 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默认只对RuntimeException和Error回滚。如果用受检异常,还得写@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 MVC 的 ExceptionHandlerExceptionResolver 在匹配异常处理器时,遵循最精确匹配原则:
#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);
进一步的健壮性建议
生产环境还可以加一层主动校验,避免依赖 Nullable 的 id:
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 条关联数据。MyBatis 的 Total 表示结果集行数。
场景三:套餐校验
由于 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 = 0,msg 为提示文案 |
官方文档
- Spring MVC 异常处理:官方文档
- MyBatis-Plus IService CRUD 接口:官方文档
- MyBatis-Plus 条件构造器 eq:官方文档
- MySQL 外键约束与 InnoDB:官方文档
总结
这篇文章把「删除分类」这个看似简单的功能,从第一版直接 removeById 删掉,到发现问题、再一步步完善成带关联校验的完整方案,整个过程走了一遍。
核心就三件事:
- 删除前先查关联 :用
LambdaQueryWrapper分别查dish和setmeal表,count()大于 0 就说明有东西挂着,不能删。 - 自定义异常 :
CustomException extends RuntimeException,配合GlobalExceptionHandler把「当前分类下关联了菜品/套餐,不能删除」这种文案原样抛给前端,用户一看就懂。 - 一个容易踩的坑 :
setmealService.count()漏传条件构造器,导致只要套餐表有数据,所有分类都删不掉。这种 bug 藏得深,因为测试时往往先测「关联了菜品」的分支,第一个if就抛异常了,第二个分支根本走不到。
最后提醒一句:写这类关联校验,先想清楚期望的 SQL 长什么样,再照着组装条件,能少踩很多坑。