新增菜品、DishDto 数据传输对象与双表事务写入
纲要
- 需求:后台新增菜品,填写名称、分类、价格、口味做法、图片、描述,保存后移动端可见
- 数据模型 :一次新增写入两张表------
dish(菜品主表)与dish_flavor(口味明细表) - 四次交互:加载分类下拉 → 上传图片 → 下载回显 → 提交表单
DTO出场 :页面提交的flavors是数组,Dish实体没有该字段,必须用DishDto extends Dish扩展- 分类下拉接口 :
GET /category/list?type=1,复用第 29 篇的CategoryController.list - 口味
dishId回填 :save(dishDto)后雪花ID回写进dishDto.id,再用stream().map给每条口味赋值 - 批量插入 :
dishFlavorService.saveBatch(flavors) - 事务控制 :
@Transactional+ 启动类@EnableTransactionManagement - 价格单位 :前端提交前
* 100,数据库以「分」为单位存储,字段类型decimal(10,2)
原型页面
doc
┌──────────────────────────────────────────────────────────────┐
│ ← 新建菜品 │
├──────────────┬──────────────────────────────────────────────┤
│ ▸ 仪表盘 │ 菜品名称:[________________] │
│ 商品管理 │ │
│ 分类管理 │ 菜品分类:[请选择 ▼] │
│ 菜品管理 ● │ 菜品价格:[______________] (¥) │
│ 订单管理 │ │
│ 用户管理 │ ┌ 口味默认配置 ─────────────────────────┐ │
│ 设置 │ │ 口味名称(3个字内) 口味标签(输入后回车添加)│ │
│ │ │ [ 默认口味 ▼ ] [标签1 ×] [标签2 ×] │ │
│ │ │ [________________] 删除 │ │
│ │ │ [ + 添加口味 ] │ │
│ │ └────────────────────────────────────────┘ │
│ │ │
│ │ 菜品图片: [ + 上传图片 ] │
│ │ (大小不超过1M,支持 png/jpeg/jpg/gif)│
│ │ │
│ │ 菜品描述:[____________________________] │
│ │ [____________________________] │
│ │ (最长100字) │
│ │ │
│ │ ┌──────────────────────────────────────┐ │
│ │ │ 💡 是否启用打印机:选择是,则显示在打印机列表 │ │
│ │ │ 提示(橙色说明块) │ │
│ │ └──────────────────────────────────────┘ │
│ │ │
│ │ [ 保存 ] [ 保存并继续添加菜品 ] [ 取消 ] │
└──────────────┴──────────────────────────────────────────────┘
需求分析
新增菜品是后台最复杂的一个表单。字段横跨文本、下拉选择、图片上传、动态多组口味,最终落到两张表。
表单字段与落库映射
| 表单项 | 控件 | 提交字段 | 落库表 | 落库列 |
|---|---|---|---|---|
| 菜品名称 | el-input |
name |
dish |
name(唯一约束) |
| 菜品分类 | el-select |
categoryId |
dish |
category_id |
| 菜品价格 | el-input |
price |
dish |
price(单位:分) |
| 口味做法 | 动态行 | flavors[] |
dish_flavor |
name / value |
| 图片 | el-upload |
image |
dish |
image(文件名) |
| 菜品描述 | el-textarea |
description |
dish |
description |
| 售卖状态 | 隐藏默认值 | status |
dish |
status(默认 1 起售) |
口味数据的形态
口味是多维度、多值的结构。页面上每一行包含「口味名称(甜度/温度/辣度/忌口)」和「可选的多个值」:
json
"flavors": [
{"name": "甜味", "value": "[\"无糖\",\"少糖\",\"半糖\",\"多糖\",\"全糖\"]"},
{"name": "温度", "value": "[\"热饮\",\"常温\",\"去冰\",\"少冰\",\"多冰\"]"}
]
注意 value 是一个 JSON 字符串 (数组被序列化后的字符串),不是数组。数据库里 dish_flavor.value 的类型是 varchar(500),存的就是这串文本。
sql
CREATE TABLE `dish_flavor` (
`id` bigint(20) NOT NULL COMMENT '主键',
`dish_id` bigint(20) NOT NULL COMMENT '菜品',
`name` varchar(64) COLLATE utf8_bin NOT NULL COMMENT '口味名称',
`value` varchar(500) COLLATE utf8_bin DEFAULT NULL COMMENT '口味数据list',
`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 '修改人',
`is_deleted` int(11) NOT NULL DEFAULT '0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='菜品口味关系表';
同一个菜品有几条口味记录,取决于用户添加了多少个维度。所以 dish_id 在表里会重复出现------这是一对多关系的典型表现。
四次交互过程
新增菜品的全流程比之前任何功能都长,共有四次服务端往返:
磁盘 MySQL DishController CommonController CategoryController add.html 用户 磁盘 MySQL DishController CommonController CategoryController add.html 用户 #mermaid-svg-wuDrUiiMlU1SBvsM{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-wuDrUiiMlU1SBvsM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wuDrUiiMlU1SBvsM .error-icon{fill:#552222;}#mermaid-svg-wuDrUiiMlU1SBvsM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wuDrUiiMlU1SBvsM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wuDrUiiMlU1SBvsM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wuDrUiiMlU1SBvsM .marker.cross{stroke:#333333;}#mermaid-svg-wuDrUiiMlU1SBvsM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wuDrUiiMlU1SBvsM p{margin:0;}#mermaid-svg-wuDrUiiMlU1SBvsM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wuDrUiiMlU1SBvsM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-wuDrUiiMlU1SBvsM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-wuDrUiiMlU1SBvsM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-wuDrUiiMlU1SBvsM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-wuDrUiiMlU1SBvsM .sequenceNumber{fill:white;}#mermaid-svg-wuDrUiiMlU1SBvsM #sequencenumber{fill:#333;}#mermaid-svg-wuDrUiiMlU1SBvsM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-wuDrUiiMlU1SBvsM .messageText{fill:#333;stroke:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wuDrUiiMlU1SBvsM .labelText,#mermaid-svg-wuDrUiiMlU1SBvsM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .loopText,#mermaid-svg-wuDrUiiMlU1SBvsM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .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-wuDrUiiMlU1SBvsM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-wuDrUiiMlU1SBvsM .noteText,#mermaid-svg-wuDrUiiMlU1SBvsM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-wuDrUiiMlU1SBvsM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wuDrUiiMlU1SBvsM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wuDrUiiMlU1SBvsM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-wuDrUiiMlU1SBvsM .actorPopupMenu{position:absolute;}#mermaid-svg-wuDrUiiMlU1SBvsM .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-wuDrUiiMlU1SBvsM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-wuDrUiiMlU1SBvsM .actor-man circle,#mermaid-svg-wuDrUiiMlU1SBvsM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-wuDrUiiMlU1SBvsM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击「新建菜品」,跳转到 add.html GET /category/list?type=1 SELECT * FROM category WHERE type=1 ORDER BY sort ASC, update_time DESC 分类列表 R.success(List<Category>) 填充 el-select 下拉框 选择图片 POST /common/upload (multipart) 转存为 uuid.jpg R.success("uuid.jpg") GET /common/download?name=uuid.jpg 图片字节流(回显) 填完表单,点保存 POST /dish (JSON: DishDto) INSERT INTO dish ... INSERT INTO dish_flavor ... (批量) R.success("新增菜品成功") 跳转回列表页
其中前三次(分类、上传、下载)在第 29、33、34 篇已完成,本篇重点是第四次------表单提交。
第一步:分类下拉数据
前端触发
add.html 的 created 钩子:
js
created() {
this.getDishList()
...
},
methods: {
// 获取菜品分类
getDishList() {
getCategoryList({ type: 1 }).then(res => {
if (res.code === 1) {
this.dishList = res.data
}
})
}
}
js
// 查询分类列表
function getCategoryList(params) {
return $axios({
url: '/category/list',
method: 'get',
params
})
}
后端接口
这个接口在第 29 篇已经写好,只是当时没有消费方:
java
@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);
}
为什么用 Category 实体接参数而不是 Integer type
两种写法都能工作:
java
// 写法一:直接接收类型
public R<List<Category>> list(Integer type) { ... }
// 写法二:实体封装(本项目采用)
public R<List<Category>> list(Category category) { ... }
选实体的理由:
| 维度 | Integer type |
Category category |
|---|---|---|
| 当前需求 | 够用 | 够用 |
后续扩展(如加 name 模糊查询) |
需改方法签名 | 无需改动,页面多传一个参数即可 |
与 LambdaQueryWrapper 配合 |
需手动判空 | 可直接用 category.getType() != null |
Spring MVC 会把 ?type=1 绑定到 Category 的 type 属性上,这是 ModelAttributeMethodProcessor 的数据绑定能力。
排序的双字段设计
java
queryWrapper.orderByAsc(Category::getSort)
.orderByDesc(Category::getUpdateTime);
生成的 SQL:
sql
SELECT id,type,name,sort,create_time,update_time,create_user,update_user
FROM category
WHERE (type = ?)
ORDER BY sort ASC, update_time DESC
先按 sort 升序保证业务顺序;sort 相同时按 update_time 降序,最近改过的分类排前面,方便管理员快速找到刚编辑的项。
测试
新建菜品页面打开后,Network 面板看到:
txt
Request URL: http://localhost:8080/category/list?type=1
Request Method: GET
Status Code: 200
Response:
{"code":1,"data":[{"id":"1397844263642378242","type":1,"name":"湘菜","sort":1}, ...]}
下拉框自动填充所有菜品分类。
第二步:DTO------为什么 Dish 实体接不住数据
提交的请求体
json
{
"name": "宫保鸡丁",
"categoryId": "1397844303408570369",
"price": 2000,
"code": "",
"image": "6a8f1c2e-3b4d-4f5a-9c8e-1d2f3a4b5c6d.jpg",
"description": "好吃",
"status": 1,
"flavors": [
{"name": "甜味", "value": "[\"无糖\",\"少糖\",\"半糖\"]"},
{"name": "温度", "value": "[\"热饮\",\"常温\"]"}
]
}
问题
Dish 实体只有 name/categoryId/price/code/image/description/status/sort 及四个公共字段,没有 flavors 。如果用 Dish 接收:
java
@PostMapping
public R<String> save(@RequestBody Dish dish) { ... } // flavors 被丢弃
Jackson 遇到未知属性 flavors 时,默认行为是忽略 (如果配置了 FAIL_ON_UNKNOWN_PROPERTIES 则会报错),结果口味数据全部丢失。
DTO 是什么
DTO(Data Transfer Object,数据传输对象)用于展示层与服务层之间的数据传递。它与实体的区别在于:
| 对比 | 实体 Entity |
DTO |
|---|---|---|
| 映射关系 | 与数据库表一一对应 | 与页面交互的数据结构对应 |
| 字段来源 | 表字段 | 可以跨表组合、可扩展 |
| 典型用途 | ORM 映射 |
接收复杂表单、组装响应 |
| 所在包 | entity |
dto |
DishDto
java
package com.itheima.reggie.dto;
import com.itheima.reggie.entity.Dish;
import com.itheima.reggie.entity.DishFlavor;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class DishDto extends Dish {
//菜品对应的口味数据
private List<DishFlavor> flavors = new ArrayList<>();
private String categoryName;
private Integer copies;
}
设计要点:
- 继承
Dish:直接复用菜品主表的所有字段,@Data生成的getter/setter覆盖父类属性 List<DishFlavor> flavors:接收JSON数组,泛型元素类型有name/value,与数组元素的key匹配categoryName:分页查询时用来展示分类名称(第 36 篇用)copies:移动端购物车份数(后续章节用)
Jackson 的嵌套反序列化
flavors 是 List<DishFlavor>,Jackson 会:
#mermaid-svg-zuFlrsz4bOFq8rtG{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-zuFlrsz4bOFq8rtG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zuFlrsz4bOFq8rtG .error-icon{fill:#552222;}#mermaid-svg-zuFlrsz4bOFq8rtG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zuFlrsz4bOFq8rtG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zuFlrsz4bOFq8rtG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zuFlrsz4bOFq8rtG .marker.cross{stroke:#333333;}#mermaid-svg-zuFlrsz4bOFq8rtG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zuFlrsz4bOFq8rtG p{margin:0;}#mermaid-svg-zuFlrsz4bOFq8rtG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster-label text{fill:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster-label span{color:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster-label span p{background-color:transparent;}#mermaid-svg-zuFlrsz4bOFq8rtG .label text,#mermaid-svg-zuFlrsz4bOFq8rtG span{fill:#333;color:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG .node rect,#mermaid-svg-zuFlrsz4bOFq8rtG .node circle,#mermaid-svg-zuFlrsz4bOFq8rtG .node ellipse,#mermaid-svg-zuFlrsz4bOFq8rtG .node polygon,#mermaid-svg-zuFlrsz4bOFq8rtG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zuFlrsz4bOFq8rtG .rough-node .label text,#mermaid-svg-zuFlrsz4bOFq8rtG .node .label text,#mermaid-svg-zuFlrsz4bOFq8rtG .image-shape .label,#mermaid-svg-zuFlrsz4bOFq8rtG .icon-shape .label{text-anchor:middle;}#mermaid-svg-zuFlrsz4bOFq8rtG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zuFlrsz4bOFq8rtG .rough-node .label,#mermaid-svg-zuFlrsz4bOFq8rtG .node .label,#mermaid-svg-zuFlrsz4bOFq8rtG .image-shape .label,#mermaid-svg-zuFlrsz4bOFq8rtG .icon-shape .label{text-align:center;}#mermaid-svg-zuFlrsz4bOFq8rtG .node.clickable{cursor:pointer;}#mermaid-svg-zuFlrsz4bOFq8rtG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zuFlrsz4bOFq8rtG .arrowheadPath{fill:#333333;}#mermaid-svg-zuFlrsz4bOFq8rtG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zuFlrsz4bOFq8rtG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zuFlrsz4bOFq8rtG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zuFlrsz4bOFq8rtG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zuFlrsz4bOFq8rtG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zuFlrsz4bOFq8rtG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster text{fill:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG .cluster span{color:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG 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-zuFlrsz4bOFq8rtG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zuFlrsz4bOFq8rtG rect.text{fill:none;stroke-width:0;}#mermaid-svg-zuFlrsz4bOFq8rtG .icon-shape,#mermaid-svg-zuFlrsz4bOFq8rtG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zuFlrsz4bOFq8rtG .icon-shape p,#mermaid-svg-zuFlrsz4bOFq8rtG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zuFlrsz4bOFq8rtG .icon-shape .label rect,#mermaid-svg-zuFlrsz4bOFq8rtG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zuFlrsz4bOFq8rtG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zuFlrsz4bOFq8rtG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zuFlrsz4bOFq8rtG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JSON 数组 flavors
识别目标类型 List
取出元素 DishFlavor 类型
逐个反序列化 JSON 对象
key 'name' → setName()
key 'value' → setValue()
加入 ArrayList
DishFlavor 中多余字段(id/dishId/createTime 等)在 JSON 里不存在,保持默认值 null。
忘了 @RequestBody 的后果
课程里故意演示了一次:声明 save(DishDto dishDto) 但没加 @RequestBody,断点处观察到所有字段都是 null。
原因:没有 @RequestBody 时,Spring MVC 用 ModelAttributeMethodProcessor 从请求参数 (查询串或 x-www-form-urlencoded 表单)绑定,而数据在请求体里,自然绑定不到。
记住这条规则 :Content-Type: application/json 的请求,形参前必须有 @RequestBody。
第三步:双表写入与事务
扩展 Service 接口
IService 的 save 只能操作单表,需要自定义方法:
java
public interface DishService extends IService<Dish> {
//新增菜品,同时插入菜品对应的口味数据,需要操作两张表:dish、dish_flavor
public void saveWithFlavor(DishDto dishDto);
//根据id查询菜品信息和对应的口味信息
public DishDto getByIdWithFlavor(Long id);
//更新菜品信息,同时更新对应的口味信息
public void updateWithFlavor(DishDto dishDto);
}
实现方法
java
@Service
@Slf4j
public class DishServiceImpl extends ServiceImpl<DishMapper,Dish> implements DishService {
@Autowired
private DishFlavorService dishFlavorService;
/**
* 新增菜品,同时保存对应的口味数据
* @param dishDto
*/
@Transactional
public void saveWithFlavor(DishDto dishDto) {
//保存菜品的基本信息到菜品表dish
this.save(dishDto);
Long dishId = dishDto.getId();//菜品id
//菜品口味
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item) -> {
item.setDishId(dishId);
return item;
}).collect(Collectors.toList());
//保存菜品口味数据到菜品口味表dish_flavor
dishFlavorService.saveBatch(flavors);
}
}
关键:dishId 从哪里来
MySQL MyBatis-Plus DishServiceImpl DishController MySQL MyBatis-Plus DishServiceImpl DishController #mermaid-svg-b7Cak5euBBrftRFn{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-b7Cak5euBBrftRFn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b7Cak5euBBrftRFn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b7Cak5euBBrftRFn .error-icon{fill:#552222;}#mermaid-svg-b7Cak5euBBrftRFn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b7Cak5euBBrftRFn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b7Cak5euBBrftRFn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b7Cak5euBBrftRFn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b7Cak5euBBrftRFn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b7Cak5euBBrftRFn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b7Cak5euBBrftRFn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b7Cak5euBBrftRFn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b7Cak5euBBrftRFn .marker.cross{stroke:#333333;}#mermaid-svg-b7Cak5euBBrftRFn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b7Cak5euBBrftRFn p{margin:0;}#mermaid-svg-b7Cak5euBBrftRFn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7Cak5euBBrftRFn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-b7Cak5euBBrftRFn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-b7Cak5euBBrftRFn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-b7Cak5euBBrftRFn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-b7Cak5euBBrftRFn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-b7Cak5euBBrftRFn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-b7Cak5euBBrftRFn .sequenceNumber{fill:white;}#mermaid-svg-b7Cak5euBBrftRFn #sequencenumber{fill:#333;}#mermaid-svg-b7Cak5euBBrftRFn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-b7Cak5euBBrftRFn .messageText{fill:#333;stroke:none;}#mermaid-svg-b7Cak5euBBrftRFn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7Cak5euBBrftRFn .labelText,#mermaid-svg-b7Cak5euBBrftRFn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-b7Cak5euBBrftRFn .loopText,#mermaid-svg-b7Cak5euBBrftRFn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-b7Cak5euBBrftRFn .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-b7Cak5euBBrftRFn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-b7Cak5euBBrftRFn .noteText,#mermaid-svg-b7Cak5euBBrftRFn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-b7Cak5euBBrftRFn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7Cak5euBBrftRFn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7Cak5euBBrftRFn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-b7Cak5euBBrftRFn .actorPopupMenu{position:absolute;}#mermaid-svg-b7Cak5euBBrftRFn .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-b7Cak5euBBrftRFn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-b7Cak5euBBrftRFn .actor-man circle,#mermaid-svg-b7Cak5euBBrftRFn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-b7Cak5euBBrftRFn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} dishDto.getId() == null 此刻 dishDto.getId() == 1567... saveWithFlavor(dishDto) this.save(dishDto) insert(dishDto) 雪花算法生成 id 反射回写 dishDto.id INSERT INTO dish (id, ...) VALUES (1567..., ...) dishId = dishDto.getId() 遍历 flavors,setDishId(dishId) saveBatch(flavors) INSERT INTO dish_flavor (...) VALUES (...), (...), (...)
MyBatis-Plus 的 insert 方法执行后,会通过反射把生成的主键回写到传入的实体对象 上。这个特性由 @TableId(type = IdType.ASSIGN_ID) 驱动(本项目在 application.yml 中全局配置为 id-type: ASSIGN_ID)。
所以 this.save(dishDto) 之后立刻 dishDto.getId(),就能拿到刚插入菜品的 ID。
如果
id策略配置错误(比如设为IdType.NONE且数据库无自增),这里会拿到null,导致dish_flavor.dish_id为null,触发非空约束报错。
为什么用 stream().map 而不是 for 循环
等价的 for 循环写法:
java
List<DishFlavor> flavors = dishDto.getFlavors();
for (DishFlavor flavor : flavors) {
flavor.setDishId(dishId);
}
dishFlavorService.saveBatch(flavors);
因为 DishFlavor 是引用类型,map 里 item.setDishId(...) 修改的是原对象,所以两种写法效果完全相同。stream 版本的好处是可以链式接其他操作(filter、sorted 等),在复杂场景下更简洁。
注意 collect(Collectors.toList()) 返回的是新列表 ,但元素引用未变,所以 flavors = flavors.stream()... 的重新赋值主要是为了代码连贯性。
saveBatch 的批量插入
IService.saveBatch(Collection<T>) 默认每 1000 条拼一个 INSERT 语句:
sql
INSERT INTO dish_flavor ( id, dish_id, name, value, create_time, update_time, create_user, update_user, is_deleted )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?, ?, ?, ?)
三条口味数据拼成一条 SQL,一次网络往返完成。
事务控制
java
@Transactional
public void saveWithFlavor(DishDto dishDto) { ... }
为什么必须加
不加事务时,如果 INSERT dish 成功但 INSERT dish_flavor 失败(比如 value 超长、dish_id 为 null),数据库里会留下一条没有口味的菜品------数据不一致。
加了 @Transactional 后,方法内任何一步抛异常,前面已执行的 INSERT 全部回滚。
启用事务支持
java
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
log.info("项目启动成功...");
}
}
Spring Boot的TransactionAutoConfiguration在检测到spring-tx和DataSource时会自动开启事务管理,@EnableTransactionManagement实际上是可选的。但显式声明能让意图更清晰,也是课程的写法。
@Transactional 的生效条件
#mermaid-svg-WZNzQkMCPOGYp7V4{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-WZNzQkMCPOGYp7V4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WZNzQkMCPOGYp7V4 .error-icon{fill:#552222;}#mermaid-svg-WZNzQkMCPOGYp7V4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WZNzQkMCPOGYp7V4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .marker.cross{stroke:#333333;}#mermaid-svg-WZNzQkMCPOGYp7V4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WZNzQkMCPOGYp7V4 p{margin:0;}#mermaid-svg-WZNzQkMCPOGYp7V4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster-label text{fill:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster-label span{color:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster-label span p{background-color:transparent;}#mermaid-svg-WZNzQkMCPOGYp7V4 .label text,#mermaid-svg-WZNzQkMCPOGYp7V4 span{fill:#333;color:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .node rect,#mermaid-svg-WZNzQkMCPOGYp7V4 .node circle,#mermaid-svg-WZNzQkMCPOGYp7V4 .node ellipse,#mermaid-svg-WZNzQkMCPOGYp7V4 .node polygon,#mermaid-svg-WZNzQkMCPOGYp7V4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .rough-node .label text,#mermaid-svg-WZNzQkMCPOGYp7V4 .node .label text,#mermaid-svg-WZNzQkMCPOGYp7V4 .image-shape .label,#mermaid-svg-WZNzQkMCPOGYp7V4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-WZNzQkMCPOGYp7V4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .rough-node .label,#mermaid-svg-WZNzQkMCPOGYp7V4 .node .label,#mermaid-svg-WZNzQkMCPOGYp7V4 .image-shape .label,#mermaid-svg-WZNzQkMCPOGYp7V4 .icon-shape .label{text-align:center;}#mermaid-svg-WZNzQkMCPOGYp7V4 .node.clickable{cursor:pointer;}#mermaid-svg-WZNzQkMCPOGYp7V4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .arrowheadPath{fill:#333333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WZNzQkMCPOGYp7V4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WZNzQkMCPOGYp7V4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WZNzQkMCPOGYp7V4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster text{fill:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 .cluster span{color:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 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-WZNzQkMCPOGYp7V4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WZNzQkMCPOGYp7V4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-WZNzQkMCPOGYp7V4 .icon-shape,#mermaid-svg-WZNzQkMCPOGYp7V4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WZNzQkMCPOGYp7V4 .icon-shape p,#mermaid-svg-WZNzQkMCPOGYp7V4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WZNzQkMCPOGYp7V4 .icon-shape .label rect,#mermaid-svg-WZNzQkMCPOGYp7V4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WZNzQkMCPOGYp7V4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WZNzQkMCPOGYp7V4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WZNzQkMCPOGYp7V4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否(同类内部方法调用)
是
否
是
调用 saveWithFlavor
方法所在类是 Spring Bean?
事务不生效
调用来自 Spring 代理对象?
事务不生效
抛出 RuntimeException?
提交
回滚
常见失效场景:
| 场景 | 后果 | 解决 |
|---|---|---|
方法是 private / final |
无法被代理,事务失效 | 改为 public 非 final |
同类内部 this.xxx() 调用 |
绕过代理 | 注入自身代理或用 AopContext |
异常被 try-catch 吞掉 |
事务认为执行成功,提交 | 重新抛出或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 抛出受检异常 | 默认不回滚 | @Transactional(rollbackFor = Exception.class) |
Controller 方法
java
/**
* 新增菜品
* @param dishDto
* @return
*/
@PostMapping
public R<String> save(@RequestBody DishDto dishDto){
log.info(dishDto.toString());
dishService.saveWithFlavor(dishDto);
return R.success("新增菜品成功");
}
价格单位:元与分
现象
页面输入 20(元),请求体里是 price: 2000。
原因
add.html 的 submitForm 中做了转换:
js
submitForm(formName, st) {
...
const params = {
...this.ruleForm,
price: this.ruleForm.price * 100, // 元 → 分
...
}
addDish(params).then(...)
}
为什么用分存储
price 字段类型是 decimal(10,2),如果用浮点数(如 double)做金额运算会有精度丢失:
java
System.out.println(0.1 + 0.2); // 0.30000000000000004
转成整数「分」存储可以完全避免这个问题。这是金融、电商系统的通行做法。
| 方案 | 类型 | 优点 | 缺点 |
|---|---|---|---|
decimal(10,2) |
定点数 | 精确 | 性能略低 |
bigint 存分 |
整数 | 精确且快 | 展示时需 /100 |
double |
浮点 | 快 | 不精确,禁止用于金额 |
本项目数据库用 decimal(10,2),Java 侧用 BigDecimal。页面传分、数据库按 decimal 存,展示时再除以 100。
功能测试
断点观察 DishDto
在 save 方法打断点,填好表单点保存,观察 dishDto:
txt
dishDto = DishDto(
id=null,
name=宫保鸡丁,
categoryId=1397844303408570369,
price=2000,
code=,
image=6a8f1c2e-3b4d-4f5a-9c8e-1d2f3a4b5c6d.jpg,
description=好吃,
status=1,
flavors=[
DishFlavor(name=甜味, value=["无糖","少糖","半糖"]),
DishFlavor(name=温度, value=["热饮","常温"]),
DishFlavor(name=辣度, value=["不辣","微辣","中辣","重辣"])
]
)
flavors 数组长度等于页面添加的口味维度数。
控制台 SQL
txt
==> Preparing: INSERT INTO dish ( id, name, category_id, price, code, image, description, status, create_time, update_time, create_user, update_user, is_deleted ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
==> Parameters: 1567654321123456801(Long), 宫保鸡丁(String), 1397844303408570369(Long), 2000(BigDecimal), (String), 6a8f1c2e-....jpg(String), 好吃(String), 1(Integer), 2026-09-11T10:05:22(LocalDateTime), ..., 1(Long), 1(Long), 0(Integer)
<== Updates: 1
==> Preparing: INSERT INTO dish_flavor ( id, dish_id, name, value, create_time, update_time, create_user, update_user, is_deleted ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ? ) , ( ?, ?, ?, ?, ?, ?, ?, ?, ? ) , ( ?, ?, ?, ?, ?, ?, ?, ?, ? )
==> Parameters: 1567654321123456802(Long), 1567654321123456801(Long), 甜味(String), ["无糖","少糖","半糖"](String), ..., 1567654321123456803(Long), 1567654321123456801(Long), 温度(String), ..., 1567654321123456804(Long), 1567654321123456801(Long), 辣度(String), ...
<== Updates: 3
重点核对:dish_flavor 的三条记录,dish_id 都等于刚插入菜品 ID 的后几位(此处末四位 6801)。
数据库验证
sql
SELECT id, name, category_id, price, image FROM dish ORDER BY id DESC LIMIT 1;
-- 宫保鸡丁 | 1397844303408570369 | 2000.00 | 6a8f1c2e-....jpg
SELECT id, dish_id, name, value FROM dish_flavor WHERE dish_id = 1567654321123456801;
-- 3 条:甜味 / 温度 / 辣度
事务回滚验证
把 dish_flavor.value 改成一个超长字符串(超过 500 字符)再提交,INSERT dish_flavor 会失败。此时检查 dish 表------不会多出「宫保鸡丁」这条记录,说明事务生效。
完整可运行代码
目录结构
txt
reggie_take_out/
└── src/main/java/com/itheima/reggie/
├── ReggieApplication.java @EnableTransactionManagement
├── common/
│ ├── R.java
│ ├── CustomException.java
│ └── MyMetaObjecthandler.java
├── config/
│ ├── WebMvcConfig.java
│ └── MybatisPlusConfig.java
├── dto/
│ └── DishDto.java ← 本轮新增
├── entity/
│ ├── Category.java
│ ├── Dish.java
│ ├── DishFlavor.java ← 本轮新增
│ └── Setmeal.java
├── mapper/
│ ├── CategoryMapper.java
│ ├── DishMapper.java
│ ├── DishFlavorMapper.java ← 本轮新增
│ └── SetmealMapper.java
├── service/
│ ├── CategoryService.java
│ ├── DishService.java ← 扩展 saveWithFlavor
│ ├── DishFlavorService.java ← 本轮新增
│ └── impl/
│ ├── CategoryServiceImpl.java
│ ├── DishServiceImpl.java ← 实现 saveWithFlavor
│ ├── DishFlavorServiceImpl.java ← 本轮新增
│ └── SetmealServiceImpl.java
└── controller/
├── CategoryController.java list 接口
├── CommonController.java 上传下载
└── DishController.java ← 本轮新增
DishDto.java
java
package com.itheima.reggie.dto;
import com.itheima.reggie.entity.Dish;
import com.itheima.reggie.entity.DishFlavor;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class DishDto extends Dish {
//菜品对应的口味数据
private List<DishFlavor> flavors = new ArrayList<>();
private String categoryName;
private Integer copies;
}
DishFlavor.java
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 DishFlavor implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
//菜品id
private Long dishId;
//口味名称
private String name;
//口味数据list
private String value;
@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;
//是否删除
private Integer isDeleted;
}
DishFlavorMapper.java
java
package com.itheima.reggie.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.itheima.reggie.entity.DishFlavor;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DishFlavorMapper extends BaseMapper<DishFlavor> {
}
DishFlavorService.java
java
package com.itheima.reggie.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.entity.DishFlavor;
public interface DishFlavorService extends IService<DishFlavor> {
}
DishFlavorServiceImpl.java
java
package com.itheima.reggie.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.itheima.reggie.entity.DishFlavor;
import com.itheima.reggie.mapper.DishFlavorMapper;
import com.itheima.reggie.service.DishFlavorService;
import org.springframework.stereotype.Service;
@Service
public class DishFlavorServiceImpl extends ServiceImpl<DishFlavorMapper,DishFlavor> implements DishFlavorService {
}
DishService.java
java
package com.itheima.reggie.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.itheima.reggie.dto.DishDto;
import com.itheima.reggie.entity.Dish;
public interface DishService extends IService<Dish> {
//新增菜品,同时插入菜品对应的口味数据,需要操作两张表:dish、dish_flavor
public void saveWithFlavor(DishDto dishDto);
//根据id查询菜品信息和对应的口味信息
public DishDto getByIdWithFlavor(Long id);
//更新菜品信息,同时更新对应的口味信息
public void updateWithFlavor(DishDto dishDto);
}
DishServiceImpl.java(saveWithFlavor)
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.dto.DishDto;
import com.itheima.reggie.entity.Dish;
import com.itheima.reggie.entity.DishFlavor;
import com.itheima.reggie.mapper.DishMapper;
import com.itheima.reggie.service.DishFlavorService;
import com.itheima.reggie.service.DishService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class DishServiceImpl extends ServiceImpl<DishMapper,Dish> implements DishService {
@Autowired
private DishFlavorService dishFlavorService;
/**
* 新增菜品,同时保存对应的口味数据
* @param dishDto
*/
@Transactional
public void saveWithFlavor(DishDto dishDto) {
//保存菜品的基本信息到菜品表dish
this.save(dishDto);
Long dishId = dishDto.getId();//菜品id
//菜品口味
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map((item) -> {
item.setDishId(dishId);
return item;
}).collect(Collectors.toList());
//保存菜品口味数据到菜品口味表dish_flavor
dishFlavorService.saveBatch(flavors);
}
}
DishController.java(新增部分)
java
package com.itheima.reggie.controller;
import com.itheima.reggie.common.R;
import com.itheima.reggie.dto.DishDto;
import com.itheima.reggie.service.DishService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 菜品管理
*/
@RestController
@RequestMapping("/dish")
@Slf4j
public class DishController {
@Autowired
private DishService dishService;
/**
* 新增菜品
* @param dishDto
* @return
*/
@PostMapping
public R<String> save(@RequestBody DishDto dishDto){
log.info(dishDto.toString());
dishService.saveWithFlavor(dishDto);
return R.success("新增菜品成功");
}
}
CategoryController.java(分类下拉接口)
java
package com.itheima.reggie.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/category")
@Slf4j
public class CategoryController {
@Autowired
private CategoryService categoryService;
/**
* 根据条件查询分类数据
* @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);
}
}
ReggieApplication.java
java
package com.itheima.reggie;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class,args);
log.info("项目启动成功...");
}
}
前端 add.html 核心片段
html
<el-form-item label="菜品分类:" prop="categoryId">
<el-select v-model="ruleForm.categoryId" placeholder="请选择菜品分类">
<el-option
v-for="item in dishList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
js
export default {
data() {
return {
dishList: [],
ruleForm: {
name: '',
categoryId: '',
price: '',
image: '',
description: '',
flavors: []
}
}
},
created() {
this.getDishList()
},
methods: {
getDishList() {
getCategoryList({ type: 1 }).then(res => {
if (res.code === 1) {
this.dishList = res.data
}
})
},
submitForm() {
const params = {
...this.ruleForm,
price: this.ruleForm.price * 100
}
addDish(params).then(res => {
if (res.code === 1) {
this.$message.success('菜品添加成功!')
this.$router.push({ path: '/dish/list' })
} else {
this.$message.error(res.msg || '操作失败')
}
})
}
}
}
API 速览
API |
所属框架 | 作用 |
|---|---|---|
@PostMapping |
Spring MVC |
限定 POST 方法 |
@RequestBody |
Spring MVC |
反序列化 JSON 请求体 |
IService.save(T) |
MyBatis-Plus |
插入单条,并把生成的主键回写进实体 |
IService.saveBatch(Collection) |
MyBatis-Plus |
批量插入,默认每 1000 条一批 |
@Transactional |
Spring |
声明式事务 |
@EnableTransactionManagement |
Spring |
开启事务注解支持 |
IdType.ASSIGN_ID |
MyBatis-Plus |
雪花算法主键,插入后回写 id |
Stream.map(Function) |
JDK 8 |
元素转换 |
Collectors.toList() |
JDK 8 |
收集为 List |
LambdaQueryWrapper.eq(boolean, SFunction, Object) |
MyBatis-Plus |
条件为真时才拼接 |
BeanUtils.copyProperties(src, target) |
Spring |
属性拷贝 |
R.success(String) |
本项目 | 返回成功响应 |
官方文档
总结
这篇实战笔记把「新增菜品」这个功能完整拆了一遍,核心就三件事:
第一,数据接不住怎么办。 页面提交的 flavors 是数组,Dish 实体里没有这个字段,直接拿实体接收,口味数据会被 Jackson 悄悄丢掉。解决办法是新建 DishDto extends Dish,把 flavors 加进去,用 @RequestBody 接 JSON。
第二,两张表怎么一起写。 菜品主表 dish 和口味表 dish_flavor 是一对多关系。先 save(dishDto) 插入主表,MyBatis-Plus 会把雪花 ID 回写到 dishDto.id,再拿这个 ID 给每条口味 setDishId,最后 saveBatch 批量插入。整个过程用 @Transactional 包住,任何一步失败全部回滚,不会留下「没有口味的菜品」。
第三,几个容易踩的坑。 忘了 @RequestBody 字段全是 null;金额用「分」存避免浮点精度问题;@Transactional 要生效,方法得是 public 且通过代理调用,异常不能被 try-catch 吞掉。
一句话记住:页面提交什么结构,就用对应的 DTO 去接;涉及多表写入,就用事务兜底。