新增分类、category 表与 type 标志位设计
纲要
- 业务背景 :分类(
category)是菜品与套餐的归属维度,后台维护、移动端展示、菜品/套餐录入时下拉选择,三处共同依赖 - 数据模型 :
category表 8 个字段,type标志位区分菜品分类(1)与套餐分类(2),name带唯一约束idx_category_name - 工程骨架 :
entity→mapper→service→service.impl→controller五层,全部基于MyBatis-Plus的BaseMapper与IService继承 - 接口设计 :新增菜品分类与新增套餐分类共用同一个
POST /category,差异只体现在请求体type字段的取值 - 关键注解 :
@RestController、@RequestMapping、@PostMapping、@RequestBody - 异常链路 :
name重复触发DuplicateKeyException,由GlobalExceptionHandler统一捕获并回显「xxx 已存在」 - 公共字段 :
createTime/updateTime/createUser/updateUser由@TableField(fill = ...)+MyMetaObjecthandler自动填充
需求界面
左侧导航栏
- 订单明细
- 分类管理 (当前选中)
- 菜品管理
- 套餐管理
- 员工管理
顶部标题栏
分类管理 修改密码 退出登录
主内容区
新增菜品分类\] \[新增套餐分类
| 分类名称 | 分类类型 | 操作时间 | 排序 | 操作 |
|---|---|---|---|---|
| 荤菜 | 菜品分类 | 2019-01-01 11:11 | 1 | 修改 删除 ⋮ |
| 素菜 | 菜品分类 | 2019-01-01 11:11 | 2 | 修改 删除 ⋮ |
| 凉菜 | 菜品分类 | 2019-01-01 11:11 | 3 | 修改 删除 ⋮ |
| 周一套餐 | 套餐分类 | 2019-01-02 11:11 | 4 | 修改 删除 ⋮ |
右侧表单区
新建菜品分类
分类名称:********************
排 序:********************
保存\] \[保存并继续添加\] \[取消
新建套餐分类
分类名称:********************
排 序:********************
保存\] \[保存并继续添加\] \[取消
交互说明
-
分类管理
1.1 分类管理包括菜品分类、套餐分类
-
新增菜品分类、套餐分类
2.1 点击新增菜品分类弹出新增菜品分类弹窗
2.2 点击新增套餐分类弹出新增套餐分类弹窗
-
分类列表
3.1 显示字段:分类名称、分类类型、操作时间、操作。排序
3.2 排序:创建时间的倒序排列,单页最多显示10条
-
操作
4.1 修改 可以修改分类信息
4.2 删除 可以删除分类信息
4.3 排序 用户可以调整分类顺序
分类在系统中的位置
分类不是个孤立的功能,它把后台和移动端串成了一条线。先把这条线捋清楚,后面做菜品管理、套餐管理、移动端展示的时候会顺手很多。
三个消费场景
#mermaid-svg-IReEmVXe431uYfpi{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-IReEmVXe431uYfpi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IReEmVXe431uYfpi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IReEmVXe431uYfpi .error-icon{fill:#552222;}#mermaid-svg-IReEmVXe431uYfpi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IReEmVXe431uYfpi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IReEmVXe431uYfpi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IReEmVXe431uYfpi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IReEmVXe431uYfpi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IReEmVXe431uYfpi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IReEmVXe431uYfpi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IReEmVXe431uYfpi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IReEmVXe431uYfpi .marker.cross{stroke:#333333;}#mermaid-svg-IReEmVXe431uYfpi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IReEmVXe431uYfpi p{margin:0;}#mermaid-svg-IReEmVXe431uYfpi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IReEmVXe431uYfpi .cluster-label text{fill:#333;}#mermaid-svg-IReEmVXe431uYfpi .cluster-label span{color:#333;}#mermaid-svg-IReEmVXe431uYfpi .cluster-label span p{background-color:transparent;}#mermaid-svg-IReEmVXe431uYfpi .label text,#mermaid-svg-IReEmVXe431uYfpi span{fill:#333;color:#333;}#mermaid-svg-IReEmVXe431uYfpi .node rect,#mermaid-svg-IReEmVXe431uYfpi .node circle,#mermaid-svg-IReEmVXe431uYfpi .node ellipse,#mermaid-svg-IReEmVXe431uYfpi .node polygon,#mermaid-svg-IReEmVXe431uYfpi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IReEmVXe431uYfpi .rough-node .label text,#mermaid-svg-IReEmVXe431uYfpi .node .label text,#mermaid-svg-IReEmVXe431uYfpi .image-shape .label,#mermaid-svg-IReEmVXe431uYfpi .icon-shape .label{text-anchor:middle;}#mermaid-svg-IReEmVXe431uYfpi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IReEmVXe431uYfpi .rough-node .label,#mermaid-svg-IReEmVXe431uYfpi .node .label,#mermaid-svg-IReEmVXe431uYfpi .image-shape .label,#mermaid-svg-IReEmVXe431uYfpi .icon-shape .label{text-align:center;}#mermaid-svg-IReEmVXe431uYfpi .node.clickable{cursor:pointer;}#mermaid-svg-IReEmVXe431uYfpi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IReEmVXe431uYfpi .arrowheadPath{fill:#333333;}#mermaid-svg-IReEmVXe431uYfpi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IReEmVXe431uYfpi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IReEmVXe431uYfpi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IReEmVXe431uYfpi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IReEmVXe431uYfpi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IReEmVXe431uYfpi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IReEmVXe431uYfpi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IReEmVXe431uYfpi .cluster text{fill:#333;}#mermaid-svg-IReEmVXe431uYfpi .cluster span{color:#333;}#mermaid-svg-IReEmVXe431uYfpi 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-IReEmVXe431uYfpi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IReEmVXe431uYfpi rect.text{fill:none;stroke-width:0;}#mermaid-svg-IReEmVXe431uYfpi .icon-shape,#mermaid-svg-IReEmVXe431uYfpi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IReEmVXe431uYfpi .icon-shape p,#mermaid-svg-IReEmVXe431uYfpi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IReEmVXe431uYfpi .icon-shape .label rect,#mermaid-svg-IReEmVXe431uYfpi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IReEmVXe431uYfpi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IReEmVXe431uYfpi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IReEmVXe431uYfpi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} category 分类表
后台: 新增菜品时选择菜品分类
后台: 新增套餐时选择套餐分类
移动端: 左侧分类栏 + 右侧菜品列表
dish.category_id
setmeal.category_id
按 sort 升序展示
- 录入侧 :新增菜品页面的「菜品分类」下拉框,数据源就是
type = 1的分类集合;新增套餐页面同理,取type = 2 - 展示侧:移动端首页左侧是一列分类(精品热菜、超值午餐、特色香菜...),点击某个分类,右侧渲染该分类下的菜品或套餐
- 排序侧 :
sort字段决定分类在移动端的展示顺序。这个字段在后台录入时填写,值越小越靠前
为什么一个表存两种分类
菜品分类和套餐分类的字段一模一样:id、type、name、sort 再加四个公共字段。要是拆成两张表,mapper/service/controller 全得写两遍,查询逻辑九成都是重复的。用一个 type 标志位区分,就是典型的**单表继承(Single Table Inheritance)**思路。
| 方案 | 表数量 | 代码量 | 查询复杂度 | 适用前提 |
|---|---|---|---|---|
| 两张表 | 2 | 翻倍 | 需要 UNION 或分别查询 |
两类字段差异大 |
单表 + type |
1 | 一套 | WHERE type = ? |
字段结构一致 |
外卖项目里两类分类的字段完全相同,所以选单表 + type。
数据模型:category 表
建表语句
sql
CREATE TABLE `category` (
`id` bigint(20) NOT NULL COMMENT '主键',
`type` int(11) DEFAULT NULL COMMENT '类型 1 菜品分类 2 套餐分类',
`name` varchar(64) COLLATE utf8_bin NOT NULL COMMENT '分类名称',
`sort` int(11) NOT NULL DEFAULT '0' COMMENT '顺序',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`create_user` bigint(20) NOT NULL COMMENT '创建人',
`update_user` bigint(20) NOT NULL COMMENT '修改人',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_category_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='菜品及套餐分类';
字段逐一说明
| 字段 | 类型 | 约束 | 含义与设计意图 |
|---|---|---|---|
id |
bigint(20) |
主键 | 雪花算法生成的 19 位 Long,不是数据库自增 |
type |
int(11) |
可空 | 核心标志位 ,1 菜品分类,2 套餐分类 |
name |
varchar(64) |
非空 + 唯一 | 唯一约束 idx_category_name 保证分类名不重复 |
sort |
int(11) |
非空,默认 0 |
移动端展示顺序,升序排列 |
create_time |
datetime |
非空 | 插入时由 MetaObjectHandler 填充 |
update_time |
datetime |
非空 | 插入与更新时均填充 |
create_user |
bigint(20) |
非空 | 插入时填充,取当前登录员工 id |
update_user |
bigint(20) |
非空 | 插入与更新时填充 |
utf8_bin 排序规则的坑
表的 COLLATE 是 utf8_bin,这是二进制比较,意味着:
- 大小写敏感:
'ChuanCai'与'chuancai'是两条不同记录 - 但对中文而言,二进制比较按
UTF-8字节序,与拼音顺序无关
如果业务上要求分类名不区分大小写,需要把 name 的排序规则改成 utf8_general_ci,或者在 Service 层做归一化(转小写再入库)。
name 唯一约束的连锁反应
唯一约束是数据库层面的最后一道防线。当插入重复名称时,MySQL 抛出的异常链路是:
GlobalExceptionHandler MySQL CategoryService CategoryController 浏览器 GlobalExceptionHandler MySQL CategoryService CategoryController 浏览器 #mermaid-svg-QljvrYb3jBO5xxTV{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-QljvrYb3jBO5xxTV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QljvrYb3jBO5xxTV .error-icon{fill:#552222;}#mermaid-svg-QljvrYb3jBO5xxTV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QljvrYb3jBO5xxTV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QljvrYb3jBO5xxTV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QljvrYb3jBO5xxTV .marker.cross{stroke:#333333;}#mermaid-svg-QljvrYb3jBO5xxTV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QljvrYb3jBO5xxTV p{margin:0;}#mermaid-svg-QljvrYb3jBO5xxTV .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QljvrYb3jBO5xxTV text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QljvrYb3jBO5xxTV .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QljvrYb3jBO5xxTV .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QljvrYb3jBO5xxTV .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QljvrYb3jBO5xxTV .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QljvrYb3jBO5xxTV #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QljvrYb3jBO5xxTV .sequenceNumber{fill:white;}#mermaid-svg-QljvrYb3jBO5xxTV #sequencenumber{fill:#333;}#mermaid-svg-QljvrYb3jBO5xxTV #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QljvrYb3jBO5xxTV .messageText{fill:#333;stroke:none;}#mermaid-svg-QljvrYb3jBO5xxTV .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QljvrYb3jBO5xxTV .labelText,#mermaid-svg-QljvrYb3jBO5xxTV .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QljvrYb3jBO5xxTV .loopText,#mermaid-svg-QljvrYb3jBO5xxTV .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QljvrYb3jBO5xxTV .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QljvrYb3jBO5xxTV .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QljvrYb3jBO5xxTV .noteText,#mermaid-svg-QljvrYb3jBO5xxTV .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QljvrYb3jBO5xxTV .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QljvrYb3jBO5xxTV .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QljvrYb3jBO5xxTV .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QljvrYb3jBO5xxTV .actorPopupMenu{position:absolute;}#mermaid-svg-QljvrYb3jBO5xxTV .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QljvrYb3jBO5xxTV .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QljvrYb3jBO5xxTV .actor-man circle,#mermaid-svg-QljvrYb3jBO5xxTV line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QljvrYb3jBO5xxTV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /category {name:"川菜", type:1, sort:10} save(category) INSERT INTO category ... 1062 Duplicate entry '川菜' for key 'idx_category_name' DuplicateKeyException 异常冒泡 匹配 SQLIntegrityConstraintViolationException 分支 截取 message 中 duplicated 的部分 {"code":0, "msg":"川菜已存在"} Element UI 弹出红色提示条
这个流程不需要在 Controller 里写任何 try-catch,全部由第 24 篇实现的 GlobalExceptionHandler 兜底。
执行流程梳理
交互全过程
MySQL CategoryMapper CategoryServiceImpl CategoryController category.js Vue 页面 用户 MySQL CategoryMapper CategoryServiceImpl CategoryController category.js Vue 页面 用户 #mermaid-svg-7x4lYvagBF48UXIp{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-7x4lYvagBF48UXIp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7x4lYvagBF48UXIp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7x4lYvagBF48UXIp .error-icon{fill:#552222;}#mermaid-svg-7x4lYvagBF48UXIp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7x4lYvagBF48UXIp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7x4lYvagBF48UXIp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7x4lYvagBF48UXIp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7x4lYvagBF48UXIp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7x4lYvagBF48UXIp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7x4lYvagBF48UXIp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7x4lYvagBF48UXIp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7x4lYvagBF48UXIp .marker.cross{stroke:#333333;}#mermaid-svg-7x4lYvagBF48UXIp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7x4lYvagBF48UXIp p{margin:0;}#mermaid-svg-7x4lYvagBF48UXIp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7x4lYvagBF48UXIp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7x4lYvagBF48UXIp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7x4lYvagBF48UXIp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-7x4lYvagBF48UXIp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7x4lYvagBF48UXIp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7x4lYvagBF48UXIp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7x4lYvagBF48UXIp .sequenceNumber{fill:white;}#mermaid-svg-7x4lYvagBF48UXIp #sequencenumber{fill:#333;}#mermaid-svg-7x4lYvagBF48UXIp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7x4lYvagBF48UXIp .messageText{fill:#333;stroke:none;}#mermaid-svg-7x4lYvagBF48UXIp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7x4lYvagBF48UXIp .labelText,#mermaid-svg-7x4lYvagBF48UXIp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7x4lYvagBF48UXIp .loopText,#mermaid-svg-7x4lYvagBF48UXIp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7x4lYvagBF48UXIp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7x4lYvagBF48UXIp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7x4lYvagBF48UXIp .noteText,#mermaid-svg-7x4lYvagBF48UXIp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7x4lYvagBF48UXIp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7x4lYvagBF48UXIp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7x4lYvagBF48UXIp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7x4lYvagBF48UXIp .actorPopupMenu{position:absolute;}#mermaid-svg-7x4lYvagBF48UXIp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-7x4lYvagBF48UXIp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7x4lYvagBF48UXIp .actor-man circle,#mermaid-svg-7x4lYvagBF48UXIp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7x4lYvagBF48UXIp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击「新增菜品分类」 addClass('add') 弹出窗口,标题设为「新增菜品分类」 输入名称「川菜」、排序「10」,点确定 submitForm() 触发校验 addCategory({name:'川菜', type:1, sort:10}) POST /category (application/json) @RequestBody 反序列化为 Category save(category) insert(category) INSERT INTO category ... 影响行数 1 1 true R.success("新增分类成功") res.code === 1 → 提示成功并刷新列表
关键观察:两个窗口,一个接口
页面上有两个按钮:「新增菜品分类」和「新增套餐分类」。但从抓包结果看,两者请求完全一致:
| 对比项 | 新增菜品分类 | 新增套餐分类 |
|---|---|---|
| 请求地址 | /category |
/category |
| 请求方式 | POST |
POST |
Content-Type |
application/json |
application/json |
| 请求体 | {"name":"川菜","type":1,"sort":10} |
{"name":"精品套餐","type":2,"sort":12} |
唯一区别是 type 的取值。这个 type 不是用户输入的,是页面根据点击的按钮写死的。所以服务端只需要一个方法,这是典型的「前端差异参数化、后端统一处理」设计。
工程骨架搭建
在写具体方法之前,先把分类模块涉及的类全部建出来。顺序固定为:实体类 → Mapper → Service 接口 → Service 实现类 → Controller。
目录结构
txt
reggie_take_out/
├── pom.xml
└── src/
├── main/
│ ├── java/com/itheima/reggie/
│ │ ├── ReggieApplication.java 项目启动类
│ │ ├── common/
│ │ │ ├── R.java 统一响应结果封装
│ │ │ ├── CustomException.java 自定义业务异常
│ │ │ ├── BaseContext.java ThreadLocal 上下文
│ │ │ ├── JacksonObjectMapper.java 自定义消息转换器
│ │ │ ├── MyMetaObjecthandler.java 公共字段自动填充
│ │ │ └── GlobalExceptionHandler.java 全局异常处理
│ │ ├── config/
│ │ │ ├── WebMvcConfig.java 静态资源映射 + 消息转换器
│ │ │ └── MybatisPlusConfig.java 分页插件
│ │ ├── filter/
│ │ │ └── LoginCheckFilter.java 登录校验过滤器
│ │ ├── entity/
│ │ │ ├── Employee.java
│ │ │ └── Category.java ← 本轮新增
│ │ ├── mapper/
│ │ │ ├── EmployeeMapper.java
│ │ │ └── CategoryMapper.java ← 本轮新增
│ │ ├── service/
│ │ │ ├── EmployeeService.java
│ │ │ ├── CategoryService.java ← 本轮新增
│ │ │ └── impl/
│ │ │ ├── EmployeeServiceImpl.java
│ │ │ └── CategoryServiceImpl.java ← 本轮新增
│ │ └── controller/
│ │ ├── EmployeeController.java
│ │ └── CategoryController.java ← 本轮新增
│ └── resources/
│ ├── application.yml
│ └── backend/page/category/list.html 分类管理页面
└── test/
实体类 Category
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.time.LocalDateTime;
/**
* 分类
*/
@Data
public class Category implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//类型 1 菜品分类 2 套餐分类
private Integer type;
//分类名称
private String name;
//顺序
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;
}
几个细节:
id上没有@TableId,因为application.yml中配置了全局id-type: assign_id(雪花算法),全局配置优先于默认值- 四个公共字段加了
@TableField(fill = ...),配合第 28 篇的MyMetaObjecthandler自动赋值 - 实现
Serializable并声明serialVersionUID,虽然本项目不依赖JDK序列化,但这是MyBatis-Plus实体的推荐写法
Mapper 接口
java
package com.itheima.reggie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Category;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}
继承 BaseMapper<Category> 之后,insert、deleteById、updateById、selectById、selectList 等单表 CRUD 全部就位,无需写 XML。
Service 接口与实现类
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);
}
java
package com.itheima.reggie.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.Category;
import com.itheima.reggie.mapper.CategoryMapper;
import com.itheima.reggie.service.CategoryService;
import org.springframework.stereotype.Service;
@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category>
implements CategoryService {
}
ServiceImpl<M extends BaseMapper<T>, T> 是 MyBatis-Plus 提供的通用实现,第一个泛型是 Mapper 接口,第二个是实体类型。继承之后 Service 层也白拿了 save、removeById、updateById、page、list、count 等方法。
上面的 remove(Long id) 是后面删除分类功能要扩展的自定义方法,本轮先声明,第 31 篇实现。
新增分类的实现
Controller 方法
java
/**
* 新增分类
* @param category
* @return
*/
@PostMapping
public R<String> save(@RequestBody Category category) {
log.info("category:{}", category);
categoryService.save(category);
return R.success("新增分类成功");
}
代码虽然只有三行,但背后有几个点值得琢磨:
为什么 @PostMapping 不加路径
类上的 @RequestMapping("/category") 已经声明了根路径,方法上只写 @PostMapping 表示映射到 /category 本身。前端请求的正是 /category,两者刚好匹配。如果写成 @PostMapping("/add"),前端就得改请求地址。
这是 RESTful 风格的一个约定:
| 语义 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 新增 | POST |
/category |
不带子路径,资源集合本身 |
| 删除 | DELETE |
/category?id=xxx |
资源集合 + 查询参数 |
| 修改 | PUT |
/category |
资源集合本身,数据放请求体 |
| 查询单个 | GET |
/category/{id} |
资源实例 |
| 分页查询 | GET |
/category/page |
子路径区分语义 |
| 条件列表 | GET |
/category/list |
子路径区分语义 |
为什么必须用 @RequestBody
前端通过 axios 发送的是 application/json,数据在请求体 里,不是 ?name=川菜&sort=10 这种查询参数。没有 @RequestBody 时,Spring MVC 会尝试用 ModelAttributeMethodProcessor 从请求参数绑定,category 对象所有字段都是 null。
@RequestBody 的作用是把请求体交给 HttpMessageConverter(这里是 MappingJackson2HttpMessageConverter)反序列化。
为什么返回值是 R<String>
看前端 submitForm 的回调:
js
if (res.code === 1) {
this.$message.success('分类添加成功!')
...
}
回调只用了 res.code。R<String> 中的 data 是字符串提示语,前端不消费,但保留了语义完整性。R 的结构:
java
@Data
public class R<T> {
private Integer code; // 1 成功,0 失败
private String msg; // 错误信息
private T data; // 数据
private Map map = new HashMap(); // 动态数据
}
Service 层:直接复用 IService.save
categoryService.save(category) 直接调用 MyBatis-Plus 提供的方法,没有额外逻辑。生成的 SQL 是:
sql
INSERT INTO category ( id, type, name, sort, create_time, update_time, create_user, update_user )
VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
其中 id 由雪花算法生成,create_time/update_time/create_user/update_user 由 MyMetaObjecthandler 在 insertFill 阶段写入。
公共字段填充的触发时机
MyBatis Executor MyMetaObjecthandler MybatisPlusInterceptor ServiceImpl Controller MyBatis Executor MyMetaObjecthandler MybatisPlusInterceptor ServiceImpl Controller #mermaid-svg-F1f1ldx8N5IVHjAC{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-F1f1ldx8N5IVHjAC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F1f1ldx8N5IVHjAC .error-icon{fill:#552222;}#mermaid-svg-F1f1ldx8N5IVHjAC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F1f1ldx8N5IVHjAC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F1f1ldx8N5IVHjAC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F1f1ldx8N5IVHjAC .marker.cross{stroke:#333333;}#mermaid-svg-F1f1ldx8N5IVHjAC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F1f1ldx8N5IVHjAC p{margin:0;}#mermaid-svg-F1f1ldx8N5IVHjAC .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F1f1ldx8N5IVHjAC text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F1f1ldx8N5IVHjAC .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-F1f1ldx8N5IVHjAC .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-F1f1ldx8N5IVHjAC #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-F1f1ldx8N5IVHjAC .sequenceNumber{fill:white;}#mermaid-svg-F1f1ldx8N5IVHjAC #sequencenumber{fill:#333;}#mermaid-svg-F1f1ldx8N5IVHjAC #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-F1f1ldx8N5IVHjAC .messageText{fill:#333;stroke:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F1f1ldx8N5IVHjAC .labelText,#mermaid-svg-F1f1ldx8N5IVHjAC .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .loopText,#mermaid-svg-F1f1ldx8N5IVHjAC .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F1f1ldx8N5IVHjAC .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-F1f1ldx8N5IVHjAC .noteText,#mermaid-svg-F1f1ldx8N5IVHjAC .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-F1f1ldx8N5IVHjAC .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F1f1ldx8N5IVHjAC .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F1f1ldx8N5IVHjAC .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F1f1ldx8N5IVHjAC .actorPopupMenu{position:absolute;}#mermaid-svg-F1f1ldx8N5IVHjAC .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-F1f1ldx8N5IVHjAC .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F1f1ldx8N5IVHjAC .actor-man circle,#mermaid-svg-F1f1ldx8N5IVHjAC line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-F1f1ldx8N5IVHjAC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} save(category) insertFill(metaObject) 读取 @TableField(fill=INSERT) 标注的字段 setValue("createTime", LocalDateTime.now()) setValue("updateTime", LocalDateTime.now()) setValue("createUser", BaseContext.getCurrentId()) setValue("updateUser", BaseContext.getCurrentId()) 填充完成 执行 INSERT
注意 insertFill 发生在 SQL 组装之前,所以填充的值会直接进入 INSERT 语句。如果某个字段忘了加 @TableField(fill = ...),metaObject 里找不到该 setter,SQL 中该列为 null,触发非空约束报错。
功能测试与异常验证
正常流程
启动项目,登录后进入「分类管理」,点击「新增菜品分类」,输入名称「川菜」、排序「10」,点确定。
浏览器 Network 面板:
txt
Request URL: http://localhost:8080/category
Request Method: POST
Content-Type: application/json;charset=UTF-8
Request Payload:
{"name":"川菜","type":1,"sort":"10"}
Response:
{"code":1,"data":"新增分类成功","map":{}}
控制台 SQL:
txt
==> Preparing: INSERT INTO category ( id, type, name, sort, create_time, update_time, create_user, update_user ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ? )
==> Parameters: 1567654321123456789(Long), 1(Integer), 川菜(String), 10(Integer), 2026-09-11T09:12:33(LocalDateTime), ..., 1(Long), 1(Long)
<== Updates: 1
create_user 与 update_user 都是 1,来自 BaseContext 中存放的当前登录员工 id。
异常流程:分类名重复
db_reggie.sql 初始化脚本里已经有一条 川菜 数据。再次录入「川菜」:
txt
### Error updating database. Cause: java.sql.SQLIntegrityConstraintViolationException:
Duplicate entry '川菜' for key 'idx_category_name'
GlobalExceptionHandler 捕获后的处理逻辑:
java
@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("未知错误");
}
split(" ") 按空格切分异常信息,split[2] 取到 '川菜'(含单引号),拼接后返回「川菜已存在」。页面上 Element UI 的 this.$message.error(res.msg) 弹出红色提示条。
这种解析方式依赖 MySQL 异常信息的固定格式,耦合度有点高。生产环境更稳的做法是在 Service 层先查一次 count,主动抛 CustomException,第 31 篇会讲到。
已知的小问题
新增成功后页面会立即发出 GET /category/page 刷新列表,此时分页接口还没实现,控制台会出现 404。这属于开发顺序导致的正常现象,第 30 篇补上分页接口后消失。
完整可运行代码
CategoryController.java
java
package com.itheima.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.itheima.reggie.common.R;
import com.itheima.reggie.entity.Category;
import com.itheima.reggie.service.CategoryService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 分类管理
*/
@RestController
@RequestMapping("/category")
@Slf4j
public class CategoryController {
@Autowired
private CategoryService categoryService;
/**
* 新增分类
* @param category
* @return
*/
@PostMapping
public R<String> save(@RequestBody Category category){
log.info("category:{}",category);
categoryService.save(category);
return R.success("新增分类成功");
}
/**
* 分页查询
* @param page
* @param pageSize
* @return
*/
@GetMapping("/page")
public R<Page> page(int page,int pageSize){
//分页构造器
Page<Category> pageInfo = new Page<>(page,pageSize);
//条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
//添加排序条件,根据sort进行排序
queryWrapper.orderByAsc(Category::getSort);
//分页查询
categoryService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
/**
* 根据id删除分类
* @param id
* @return
*/
@DeleteMapping
public R<String> delete(Long id){
log.info("删除分类,id为:{}",id);
categoryService.remove(id);
return R.success("分类信息删除成功");
}
/**
* 根据id修改分类信息
* @param category
* @return
*/
@PutMapping
public R<String> update(@RequestBody Category category){
log.info("修改分类信息:{}",category);
categoryService.updateById(category);
return R.success("修改分类信息成功");
}
/**
* 根据条件查询分类数据
* @param category
* @return
*/
@GetMapping("/list")
public R<List<Category>> list(Category category){
//条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
//添加条件
queryWrapper.eq(category.getType() != null,Category::getType,category.getType());
//添加排序条件
queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
List<Category> list = categoryService.list(queryWrapper);
return R.success(list);
}
}
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);
}
}
代码勘误 *:仓库代码中的原始写法是 int count2 = setmealService.count();,少了 setmealLambdaQueryWrapper 参数,导致统计的是套餐表全表行数而非当前分类下的套餐数。只要套餐表有任何一条数据,所有分类都无法删除。上面给出的是修正后的版本,这也是课程「功能完善」环节实际要修复的缺陷。
CategoryMapper.java
java
package com.itheima.reggie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.Category;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CategoryMapper extends BaseMapper<Category> {
}
CustomException.java
java
package com.itheima.reggie.common;
/**
* 自定义业务异常类
*/
public class CustomException extends RuntimeException {
public CustomException(String message){
super(message);
}
}
前端接口封装 category.js
js
// 新增分类
function addCategory(params) {
return $axios({
url: '/category',
method: 'post',
data: { ...params }
})
}
前端页面核心逻辑 list.html
html
<el-button
type="primary"
@click="addClass('add')"
>
+ 新增菜品分类
</el-button>
<el-button
type="primary"
@click="addClass('add')"
>
+ 新增套餐分类
</el-button>
js
// 弹窗:新增或编辑
addClass(st) {
this.classData.title = '新增菜品分类'
this.action = 'add'
this.classData.name = ''
this.classData.sort = ''
this.dialogVisible = true
// 套餐分类的分支由页面上的 type 决定
}
type 值由页面在提交前根据当前激活的窗口写入模型数据,服务端不做区分。
API 速览
API |
所属框架 | 作用 |
|---|---|---|
@RestController |
Spring MVC |
组合 @Controller + @ResponseBody,返回值直接写入响应体 |
@RequestMapping("/category") |
Spring MVC |
类级别根路径 |
@PostMapping |
Spring MVC |
限定 POST 方法,映射到 /category |
@RequestBody |
Spring MVC |
将 JSON 请求体反序列化为 Java 对象 |
BaseMapper<T> |
MyBatis-Plus |
提供单表 CRUD,泛型为实体类型 |
@Mapper |
MyBatis |
标记接口为 Mapper,由 MyBatis 生成代理实现 |
IService<T> |
MyBatis-Plus |
Service 层通用接口,提供 save/list/page 等 |
ServiceImpl<M, T> |
MyBatis-Plus |
IService 的通用实现类 |
@TableField(fill = FieldFill.INSERT) |
MyBatis-Plus |
标记字段在插入时自动填充 |
@TableField(fill = FieldFill.INSERT_UPDATE) |
MyBatis-Plus |
标记字段在插入与更新时自动填充 |
R.success(T) / R.error(String) |
本项目 | 统一响应封装的静态工厂方法 |
SQLIntegrityConstraintViolationException |
JDBC |
违反唯一约束、非空约束时抛出 |
官方文档
Spring MVC@RequestBody:RequestBody.htmlMyBatis-Plus通用ServiceCRUD:通用 Service CRUDMyBatis-Plus自动填充功能:自动填充功能MyBatis-Plus主键策略(IdType.ASSIGN_ID):主键策略Spring MVC异常处理器@ExceptionHandler:ExceptionHandler.html
总结
这篇把「新增分类」这个功能从头到尾拆了一遍,从需求界面、数据模型到代码实现、异常处理,一条线走下来。核心就几点:
- 一张表搞定两种分类 :菜品分类和套餐分类字段完全一样,用
type标志位区分,省掉一整套重复的mapper/service/controller。 - 一个接口搞定两个按钮 :前端两个按钮只是把
type写死成1或2,后端POST /category一个方法就够,典型的「前端差异参数化、后端统一处理」。 - 公共字段交给框架 :
createTime/updateTime/createUser/updateUser用@TableField(fill = ...)+MyMetaObjecthandler自动填充,代码里不用手动赋值。 - 异常交给全局处理器 :
name重复触发DuplicateKeyException,GlobalExceptionHandler统一捕获并回显「xxx 已存在」,Controller里不用写try-catch。
下一篇会补上分页查询接口,把「新增成功后刷新列表 404」这个小问题解决掉。到时候分类管理页就能完整跑起来了。