Java 项目实战: 外卖平台-新增菜品DishDto双表插入与事务控制

新增菜品、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.htmlcreated 钩子:

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 绑定到 Categorytype 属性上,这是 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 是什么

DTOData 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 的嵌套反序列化

flavorsList<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 MVCModelAttributeMethodProcessor请求参数 (查询串或 x-www-form-urlencoded 表单)绑定,而数据在请求体里,自然绑定不到。

记住这条规则Content-Type: application/json 的请求,形参前必须有 @RequestBody

第三步:双表写入与事务

扩展 Service 接口

IServicesave 只能操作单表,需要自定义方法:

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-Plusinsert 方法执行后,会通过反射把生成的主键回写到传入的实体对象 上。这个特性由 @TableId(type = IdType.ASSIGN_ID) 驱动(本项目在 application.yml 中全局配置为 id-type: ASSIGN_ID)。

所以 this.save(dishDto) 之后立刻 dishDto.getId(),就能拿到刚插入菜品的 ID

如果 id 策略配置错误(比如设为 IdType.NONE 且数据库无自增),这里会拿到 null,导致 dish_flavor.dish_idnull,触发非空约束报错。

为什么用 stream().map 而不是 for 循环

等价的 for 循环写法:

java 复制代码
List<DishFlavor> flavors = dishDto.getFlavors();
for (DishFlavor flavor : flavors) {
    flavor.setDishId(dishId);
}
dishFlavorService.saveBatch(flavors);

因为 DishFlavor 是引用类型,mapitem.setDishId(...) 修改的是原对象,所以两种写法效果完全相同。stream 版本的好处是可以链式接其他操作(filtersorted 等),在复杂场景下更简洁。

注意 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_idnull),数据库里会留下一条没有口味的菜品------数据不一致。

加了 @Transactional 后,方法内任何一步抛异常,前面已执行的 INSERT 全部回滚。

启用事务支持
java 复制代码
@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableTransactionManagement
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class, args);
        log.info("项目启动成功...");
    }
}

Spring BootTransactionAutoConfiguration 在检测到 spring-txDataSource 时会自动开启事务管理,@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 无法被代理,事务失效 改为 publicfinal
同类内部 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.htmlsubmitForm 中做了转换:

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.javasaveWithFlavor

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 去接;涉及多表写入,就用事务兜底。

相关推荐
凤年徐1 小时前
C++ 仿 muduo 高并发服务器:日志模块
linux·服务器
狂师2 小时前
cloudflared,不需要服务器和公网 IP 的免费内网穿透,一条命令上手
服务器·程序员·开源
Wang's Blog2 小时前
Java 项目实战: 外卖平台-启用禁用员工账号与Long精度丢失修复
java·服务器
渡我白衣3 小时前
HttpRequest与HttpResponse的实现
服务器·数据结构·c++·人工智能·tcp/ip·机器学习·caffe
刃神太酷啦3 小时前
前端入门第一课:HTML 基础语法 + 常用标签 + 实战全解
服务器·c语言·前端·javascript·css·c++·html
甜到心里的蛋糕3 小时前
如何用企业微信实现让微信收到通知实时通知
服务器·python·微信小程序·fastapi
牢姐与蒯3 小时前
Linux进程间通信(三).基于匿名管道的进程池的实现
linux·运维·服务器·ubuntu
比兔代理12 小时前
正向代理与反向代理技术辨析,为什么代理 IP 属于正向代理
服务器·网络·http·ip
Wang's Blog14 小时前
Java 接入Redis: 通用命令与键管理
java·服务器·redis