Java 项目实战: 外卖平台-新增分类与type标志位设计

新增分类、category 表与 type 标志位设计

纲要

  • 业务背景 :分类(category)是菜品与套餐的归属维度,后台维护、移动端展示、菜品/套餐录入时下拉选择,三处共同依赖
  • 数据模型category 表 8 个字段,type 标志位区分菜品分类(1)与套餐分类(2),name 带唯一约束 idx_category_name
  • 工程骨架entitymapperserviceservice.implcontroller 五层,全部基于 MyBatis-PlusBaseMapperIService 继承
  • 接口设计 :新增菜品分类与新增套餐分类共用同一个 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.1 分类管理包括菜品分类、套餐分类

  2. 新增菜品分类、套餐分类

    2.1 点击新增菜品分类弹出新增菜品分类弹窗

    2.2 点击新增套餐分类弹出新增套餐分类弹窗

  3. 分类列表

    3.1 显示字段:分类名称、分类类型、操作时间、操作。排序

    3.2 排序:创建时间的倒序排列,单页最多显示10条

  4. 操作

    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 字段决定分类在移动端的展示顺序。这个字段在后台录入时填写,值越小越靠前

为什么一个表存两种分类

菜品分类和套餐分类的字段一模一样:idtypenamesort 再加四个公共字段。要是拆成两张表,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 排序规则的坑

表的 COLLATEutf8_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 不是用户输入的,是页面根据点击的按钮写死的。所以服务端只需要一个方法,这是典型的「前端差异参数化、后端统一处理」设计。

工程骨架搭建

在写具体方法之前,先把分类模块涉及的类全部建出来。顺序固定为:实体类 → MapperService 接口 → 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> 之后,insertdeleteByIdupdateByIdselectByIdselectList 等单表 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 层也白拿了 saveremoveByIdupdateByIdpagelistcount 等方法。

上面的 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.codeR<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_userMyMetaObjecthandlerinsertFill 阶段写入。

公共字段填充的触发时机

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 里找不到该 setterSQL 中该列为 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_userupdate_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 UIthis.$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 违反唯一约束、非空约束时抛出

官方文档

总结

这篇把「新增分类」这个功能从头到尾拆了一遍,从需求界面、数据模型到代码实现、异常处理,一条线走下来。核心就几点:

  • 一张表搞定两种分类 :菜品分类和套餐分类字段完全一样,用 type 标志位区分,省掉一整套重复的 mapper/service/controller
  • 一个接口搞定两个按钮 :前端两个按钮只是把 type 写死成 12,后端 POST /category 一个方法就够,典型的「前端差异参数化、后端统一处理」。
  • 公共字段交给框架createTime/updateTime/createUser/updateUser@TableField(fill = ...) + MyMetaObjecthandler 自动填充,代码里不用手动赋值。
  • 异常交给全局处理器name 重复触发 DuplicateKeyExceptionGlobalExceptionHandler 统一捕获并回显「xxx 已存在」,Controller 里不用写 try-catch

下一篇会补上分页查询接口,把「新增成功后刷新列表 404」这个小问题解决掉。到时候分类管理页就能完整跑起来了。

相关推荐
志栋智能1 小时前
集成是关键:让巡检超自动化融入现有工具链
运维·服务器·数据库·架构·自动化
M哥支付2 小时前
商户池是什么?
服务器·网络·其他·微信·金融
Wang's Blog2 小时前
Java 项目实战: 外卖平台-修改分类与新增窗口复用
服务器·项目开发
Web极客码3 小时前
抵御隐蔽的慢速连接攻击:我用 Claude 编写 nftables 规则,精准压制 HTTP Slo
服务器·copilot
fanxiaohui121383 小时前
2026年长效耐用磁吸充电宝推荐|南孚传应解锁安心出游
服务器·人工智能·手机·电池·南孚传应
Gauss松鼠会4 小时前
GaussDB 系统表与系统视图详解(内网运维版)
运维·服务器·网络·数据库·gaussdb·经验总结
小小de风呀5 小时前
de风——【从零开始学习Linux】(五):gcc编译器的基本使用
linux·运维·服务器
数智工坊5 小时前
RustDesk 自建服务器教程
运维·服务器
Julien20045 小时前
Docker 容器技术的本质
运维·服务器·ssh·学习方法