公共字段填充
在上一章节新增员工或者新增菜品分类时需要设置创建时间、创建人、修改时间、修改人等字段,在编辑员工或者编辑菜品分类时需要设置修改时间、修改人等字段。这些字段属于公共字段,也就是也就是在我们的系统中很多表中都会有这些字段,如下:
序号 字段名 含义 数据类型
1 create_time 创建时间 datetime
2 create_user 创建人id bigint
3 update_time 修改时间 datetime
4 update_user 修改人id bigint
而针对于这些字段,我们的赋值方式为:
1). 在新增数据时, 将createTime、updateTime 设置为当前时间, createUser、updateUser设置为当前登录用户ID。
2). 在更新数据时, 将updateTime 设置为当前时间, updateUser设置为当前登录用户ID。
数据表中的公共字段(多张表都有)
我们设计的数据表,多张表都有update_time,update_user等字段,有一个想法是不用每次进行数据库操作的时候都添加或修改这几个字段 ---- 可以用到spring的切面编程思想(AOP),因为面向切面可以在方法执行前进行相应的操作,将这些重复的操作与核心代码分离开,可以降低代码重复以及代码耦合程度,并且便于扩展
AOP的步骤:
1、切面类
@Aspect注解和@Component 注解来定义切面类
2、切点
@Pointcut 注解定义切点,在注解中写路径以表示需要对哪些方法进行扩展,会用到一种特殊的表达式来进行匹配需要来处理的方法
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPoint(){
}
3、通知
随后在切面类中重写通知方法即可
@Before("autoFillPoint()") 比如前置方法,在方法上加上注解即可,Before()注解内可以写切点,也可以写切点表达式
项目的具体实现
1、自定义注解 AutoFill,用于标识需要进行公共字段自动填充的方法
/**
* 自定义注解,用于标识需要自动填充的方法
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
/**
* 填充数据的操作类型
* @return
*/
OperationType value();
}
这里的OperationType是我们定义的枚举类, 作用是优化开发
/**
* 数据库操作类型
*/
public enum OperationType {
/**
* 更新操作
*/
UPDATE,
/**
* 插入操作
*/
INSERT
}
2、自定义切面类,统一拦截加入了 AutoFill 注解的方法,通过反射为公共字段赋值
/**
* 自定义切面类,用于实现公共字段填充
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
/**
* 切入点
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPoint() {
}
/**
* 前置通知,在方法执行前执行
*/
@Before("autoFillPoint()")
public void autoFill(JoinPoint joinPoint) {
log.info("开始进行数据填充");
//获取当前方法的操作类型
//获取方法签名
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
//获取方法上的注解对象
AutoFill autoFill = methodSignature.getMethod().getAnnotation(AutoFill.class);
//获取注解对象中的操作类型
OperationType operationType = autoFill.value();
//获取方法参数 --实体对象
Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) {
return;
}
Object entity = args[0];
//准备赋值的数据
LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();
//根据不同的操作类型,给对应的属性赋值
if (operationType == OperationType.INSERT) {
//为插入操作的字段赋值(4个)
try {
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
setUpdateTime.invoke(entity, now);
setCreateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
setCreateUser.invoke(entity, currentId);
} catch (Exception e) {
e.printStackTrace();
}
} else if (operationType == OperationType.UPDATE) {
//为更新操作的字段赋值(2个)
try {
Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setUpdateTime.invoke(entity, now);
setUpdateUser.invoke(entity, currentId);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
3、在 Mapper 的方法上加入 AutoFill 注解
JoinPoint 代表了程序执行过程中的一个连接点,比如方法的执行、异常的处理等。在 Spring AOP 中,连接点总是方法的执行。可以让我们获取被扩展方法的信息(反射作用 -- 这里注意自定义注解需要可以被反射,这是可以设置的)。
当通知(Advice)被执行时,JoinPoint 对象会作为第一个参数自动传递给通知方法。
新增菜品

业务规则:
- 菜品名称必须是唯一的
- 菜品必须属于某个分类下,不能单独存在
- 新增菜品时可以根据情况选择菜品的口味
- 每个菜品必须对应一张图片
表设计
我们的菜品新增的时候,就是将上面图中信息插入到dish表中,同时会涉及到菜品口味,因为菜品口味是很多的,我们另外设计了一张表dish_flavor表,后续俩张表是关联的。
文件(图片)上传功能实现
配置
现文件上传服务,需要有存储的支持,那么我们的解决方案将以下几种:
1、直接将图片保存到服务的硬盘(springmvc中的文件上传)
优点:开发便捷,成本低
缺点:扩容困难
2、使用分布式文件系统进行存储
优点:容易实现扩容
缺点:开发复杂度稍大(有成熟的产品可以使用,比如:FastDFS,MinIO)
3、使用第三方的存储服务(例如OSS)
优点:开发简单,拥有强大功能,免维护
缺点:付费(但有三个月的试用期)
在本项目选用阿里云的OSS服务进行文件存储。
需要在yaml文件中配置相关秘钥
sky:
#oss配置
alioss:
endpoint: ${sky.alioss.endpoint}
access-key-id: ${sky.alioss.access-key-id}
access-key-secret: ${sky.alioss.access-key-secret}
bucket-name: ${sky.alioss.bucket-name}
阿里云对象存储服务
配置在dev.yaml而不是yml,引用的方式 --阿里云配置信息
配置属性类 -- 从yml获取配置信息
文件上传工具类 (方法参数上需要写上配置属性类)--- 初始化工具类
下面是我们自定义的属性类,可以从上面我们写的yml文件中提取信息
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
这是我们的配置工具类 ,可以创建一个文件上传的工具类
@Configuration
@Slf4j
public class OssConfiguration {
@Bean
@ConditionalOnMissingBean
public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){
log.info("开始创建阿里云文件上传工具类,{}", aliOssProperties);
return new AliOssUtil(
aliOssProperties.getEndpoint(),
aliOssProperties.getAccessKeyId(),
aliOssProperties.getAccessKeySecret(),
aliOssProperties.getBucketName());
}
}
@ConditionalOnMissingBean只有当容器中不存在 AliOssUtil 类型的 Bean 时,才会创建这个 Bean(避免重复创建)
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求。
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder.toString());
return stringBuilder.toString();
}
}
总的来说:我们在 application.yml 里写好 OSS 相关配置,然后用 @ConfigurationProperties 把前缀对应的值绑定到自定义属性类;最后用一个 @Configuration 配置类,在满足条件时把属性类注入,完成文件上传工具类 AliOssUtil 的初始化并注册成 Bean。
具体使用
//图片
private String image;
注意到前端页面中,用户可以上传图片,所以前端传过来的DishDTO中还有一个image属性,保存了图片路径,所以类型为String。
用户上传了图片之后,后端需要将它会存储到阿里云上(阿里云对象存储服务),然后阿里云将url传递给后端,后端再传递给前端。这个过程中发生错误,会返回错误提示。
具体流程:
-
- 入口匹配
@RestController + @RequestMapping("/admin/common") 把当前类标记为处理器,
当 HTTP POST 请求打到 /admin/common/upload 时进入 upload 方法。 - 接收文件
形参 @RequestBody MultipartFile file 由 SpringMVC 自动把前端传来的 multipart/form-data 流封装成 MultipartFile 对象。 - 截取后缀
originalFilename.substring(originalFilename.lastIndexOf(".")) 拿到 ".jpg/.png" 之类后缀,用来保持图片格式。 - 生成新文件名
UUID.randomUUID().toString() + suffix → 避免中文、特殊字符或重名冲突,得到类似 a1b2c3d4.jpg 的唯一文件名。 - 上传 OSS
aliOssUtil.upload(file.getBytes(), fileName) 把字节流上传至阿里云 OSS,返回可公网访问的完整 URL(如 https://xxx.oss-cn-hangzhou.aliyuncs.com/a1b2c3d4.jpg)。 - 返回结果
上传成功 → Result.success(filePath) 把 URL 回给前端;
发生 IOException → 捕获异常记录日志,返回 Result.error(MessageConstant.UPLOAD_FAILED) 告知前端上传失败。
- 入口匹配
返回文件在OSS上的访问URL给前端, 最终在前端提交保存后,后端获取的就是图像的路径,后端接收到之后,还要返回一个路径,可以让前端看到图片,一个回显的作用
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
@Autowired
private AliOssUtil aliOssUtil;
@RequestMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(@RequestBody MultipartFile file){
log.info("文件上传:{}",file);
try {
String originalFilename = file.getOriginalFilename();
//获取文件后缀(图片类型)
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
//生成完整的文件名
String fileName = UUID.randomUUID().toString() + suffix;
//获取文件传输路径
String filePath = aliOssUtil.upload(file.getBytes(), fileName);
return Result.success(filePath);
} catch (IOException e) {
log.error("文件上传失败:{}",e);
}
//上传失败,把错误信息给前端
return Result.error(MessageConstant.UPLOAD_FAILED);
}
}
代码实现
一次插入一个菜品,同时插入多个菜品口味
@Transactional开启事务管理
适用于一个方法会进行多个数据表操作、保证数据一致性的情况
并且应该用到Service方法上,并且方法为public的
不适合只读操作,或单表操作
用DTO对象来接收前端传来的数据 -- DishDTO,内部包含了很多数据,既有菜品信息,又有菜品口味,这涉及了俩张表,所以我们用@Transactional标记方法。因为DishDTO对象中的数据太多,直接用其中数据进行表操作是可行的,但是可读性不够,所以我们要使用对象的拷贝,用Dish对象承接数据,再用Dish对象进行数据库操作。这里在进行插入操作的时候,会把插入后的主键 返回到当前的Dish对象中,从前端传来的DishDTO中是不包含id字段 的,但是我们插入之后,mybatis 会自动添加进去
useGeneratedKeys="true": 启用主键生成
keyProperty="id": 指定将生成的主键值设置到对象的哪个属性上
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO dish (name, category_id, price, status, description, image,
create_time, update_time, create_user, update_user)
VALUES (#{name}, #{categoryId}, #{price}, #{status}, #{description}, #{image},
#{createTime}, #{updateTime}, #{createUser}, #{updateUser})
</insert>
MyBatis 的实现
MyBatis 通过 JDBC 驱动与数据库交互,利用了数据库提供的机制来捕获生成的主键值。
useGeneratedKeys 属性:
这个属性使 MyBatis 在执行插入操作后,通过 JDBC 的 Statement.getGeneratedKeys() 方法获取数据库生成的主键值。
在 MyBatis 的 <insert> 标签中,设置 useGeneratedKeys="true",告诉 MyBatis 使用数据库自动生成的主键。
keyProperty 属性:
keyProperty 指定将生成的主键值设置到实体类的哪个属性中。例如,keyProperty="id" 表示将生成的主键值设置到实体类的 id 属性中。
因为后续我们**需要用到这个id去进行口味的插入操作,id在口味表中是逻辑外键,是菜品表和口味表关联的逻辑外键。**Long dishId = dish.getId(); 这句可以获得到dish_id,而我们需要的口味数据在DishDTO是一个list集合,直接获取即可。

//口味
private List<DishFlavor> flavors = new ArrayList<>();
但是因为集合中的元素只是口味信息(name,value),还需要添加菜品id操作,这样数据才完整
@Transactional
public void saveWithFlavor(DishDTO dishDTO) {
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO, dish);
//向菜品表插入1条数据
dishMapper.insert(dish);
//获取Insert结果返回的主键值
Long dishId = dish.getId();
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
for (DishFlavor flavor : flavors) {
flavor.setDishId(dishId);
}
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}
我们这里重点关注一下最后插入口味的SQL语句,是多条插入。用到了mybatis的批量插入语句,会一次性插入多条,而不是循环插入,一条一条插入。性能更好。
<insert id="insertBatch">
insert into dish_flavor (dish_id, name, value) values
<foreach collection="flavors" item="dishFlavor" separator=",">
(#{dishFlavor.dishId},#{dishFlavor.name},#{dishFlavor.value})
</foreach>
</insert>
|-----------------------------|------------------------------------------------|
| 标签/属性 | 作用 |
| <insert id="insertBatch"> | 对应 Mapper 接口里的方法名。 |
| collection="flavors" | 告诉 MyBatis 要遍历的参数名(就是 @Param("flavors") 那个)。 |
| item="dishFlavor" | 每次循环拿到的当前元素,起名 dishFlavor,下面 #{} 里用它。 |
| separator="," | 每循环一次中间加逗号,最终拼成一条合法 SQL。 |
举个例子就是:
List<DishFlavor> list = List.of(
new DishFlavor(1L, "辣度", "微辣"),
new DishFlavor(1L, "甜度", "少糖"),
new DishFlavor(2L, "温度", "冰")
);
mapper.insertBatch(list);
//上述的代码会转变为下面的SQL语句
insert into dish_flavor (dish_id, name, value)
values
(1, '辣度', '微辣'),
(1, '甜度', '少糖'),
(2, '温度', '冰');
菜品分页查询

业 务规则:
- 根据页码展示菜品信息
- 每页展示10条数据
- 分页查询时可以根据需要输入菜品名称、菜品分类、菜品状态进行查询
这里我们同样定义DTO类 来接收前端传递的参数DishPageQueryDTO我们在之前的员工管理的时候也用到了分页查询,我们当时是用了一个分页查询依赖实现的(PageHelper),这里的实现是类似的,用的是同样的工具。但是返回给前端的处理不同,在员工查询,我们用的是实体类Employee
Page<Employee> page = employeeMapper.pageQuery(employeePageQueryDTO);
但是这里,我们不同,用到了一个VO对象,而不是实体类,因为我们查询到的东西很多,不需要都展示给前端,只需要展示部分内容即可,所以我们用到了VO对象,只返回给前端需要的数据。
Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);
VO(View/Value Object)是"视图/值对象",专供前端展示或跨层传递只读数据。
核心特点:
- 只承载数据,不含业务逻辑;
- 通常设计为不可变,只暴露 getter;
- 字段按需裁剪,避免把数据库敏感结构暴露给外部;
- 生命周期一般仅在一次请求内。
典型用法:Controller 把后台数据封装成 VO,再序列化为 JSON 返回给页面或移动端 。
表连接的时候,表字段相同 --怎么解决
<select id="pageQuery" resultType="com.sky.vo.DishVO">
select d.*, c.name as categoryName from dish d left outer
join
category c
on
d.category_id = c.id
<where>
<if test="name != null and name != ''">
and d.name like concat('%',#{name},'%')
</if>
<if test="categoryId != null">
and d.category_id = #{categoryId}
</if>
<if test="status != null">
and d.status = #{status}
</if>
</where>
order by d.create_time desc
</select>
有相同的字段,可以让其中的一个起别名,比如c.name as categoryName
删除菜品
在菜品列表页面,每个菜品后面对应的操作分别为修改 、删除 、停售,可通过删除功能完成对菜品及相关的数据进行删除。
业务规则:
- 可以一次删除一个菜品,也可以批量删除菜品
- 起售中的菜品不能删除
- 被套餐关联的菜品不能删除
- 删除菜品后,关联的口味数据也需要删除掉

**注意:**删除一个菜品和批量删除菜品共用一个接口,故ids可包含多个菜品id,之间用逗号分隔。
数据库表
我们在删除菜品的时候会涉及到3张表,dish,dish_flavor,setmeal_dish
注意事项:
在dish表中删除菜品基本数据时,同时,也要把关联在dish_flavor表中的数据一块删除。
setmeal_dish表为菜品和套餐关联的中间表。
若删除的菜品数据关联着某个套餐,此时,删除失败。
若要删除套餐关联的菜品数据,先解除两者关联,再对菜品进行删除。
代码开发
前端可能会传入多个id字段,所以这里我们用一个list结构来接收数据,并且是在请求参数上的,要加上@RequestParam注解
/**
* 批量删除
* @param ids
* @return
*/
@DeleteMapping
@ApiOperation("批量删除")
public Result delete(@RequestParam List<Long> ids){
log.info("批量删除:{}",ids);
dishService.deleteBatch(ids);
//清理缓存数据
//删除所有dish_开头的缓存数据
clearCache("dish_*");
return Result.success();
}
Service层方法:
需要判断菜品是否起售,是否有关联,俩者使用了不同的查询手段,前者是循环查询,后者是使用的动态SQL查询,一次性可以查多条数据
/**
* 批量删除菜品
*
* @param ids
*/
@Override
@Transactional
public void deleteBatch(List<Long> ids) {
//判断菜品是否能够删除
//是否起售
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
if (dish.getStatus() == 1) {
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
//是否关联
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//如果菜品关联了套餐,则无法删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
//删除菜品数据
// for (Long id : ids) {
// dishMapper.deleteById(id);
// //删除菜品的口味数据
// dishFlavorMapper.deleteByDishId(id);
// }
//优化
dishMapper.deleteByIds(ids);
dishFlavorMapper.deleteByDishIds(ids);
}
后面的优化部分就是用的动态SQL
一些简单的SQL语句,我们可以直接在mapper文件中用注解的形式写,可以省略在xml文件中配置
@Select("select * from dish where id = #{id}")
Dish getById(Long id);
通过了筛选之后,就可以进行批量删除
<delete id="deleteByIds">
delete from dish where id in
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
<delete id="deleteByDishIds">
delete from dish_flavor where dish_id in
<foreach collection="dishIds" open="(" close=")" item="dishId" separator=",">
#{dishId}
</foreach>
</delete>
是使用了in关键字进行删除,因为需要同时删除多个id,这样可以进行查找然后删除,但是效率不高。其中还用到了动态sql,foreach关键字
@DeleteMapping专门用于注解DELETE操作,删除资源
再度优化方向:
如何一条sql,删除多张表中的数据?
外键约束的级联删除,主表删除了数据,相关联的表的数据也会自动删除
修改菜品
在菜品管理列表页面点击修改按钮,跳转到修改菜品页面,在修改页面回显菜品相关信息并进行修改,最后点击保存按钮完成修改操作。

接口:
- 根据id查询菜品
- 根据类型查询分类(已实现)
- 文件上传(已实现)
- 修改菜品
我们只需要实现根据id查询菜品 和修改菜品两个接口
/**
* 根据id查询菜品和对应的口味数据
* @param id
* @return
*/
@GetMapping("/{id}")
@ApiOperation("根据id查询菜品和对应的口味数据")
public Result<DishVO> getById(@PathVariable Long id){
log.info("根据id查询菜品信息:{}",id);
DishVO dishVO = dishService.getByIdWithFlavor(id);
return Result.success(dishVO);
}
/**
* 修改菜品
* @param dishDTO
* @return
*/
@PutMapping
@ApiOperation("修改菜品")
public Result update(@RequestBody DishDTO dishDTO){
log.info("编辑菜品:{}",dishDTO);
dishService.updateWithFlavor(dishDTO);
//清理缓存数据,因为有可能会修改菜品分类,这会同时影响多个缓存数据
//删除所有dish_开头的缓存数据
clearCache("dish_*");
return Result.success();
}
因为我们是修改菜品,所以请求方法可以写成PUT
查询菜品和口味
我们考虑用到VO对象,原因同样是不需要传递给前端所有的信息
我们将查询到的信息最终封装成VO对象返回给前端
@Override
public DishVO getByIdWithFlavor(Long id) {
//1.根据id查询菜品数据
Dish dish = dishMapper.getById(id);
//2.根据菜品id查询口味数据
List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);
DishVO dishVO = new DishVO();
BeanUtils.copyProperties(dish,dishVO);
dishVO.setFlavors(dishFlavors);
return dishVO;
}
修改菜品
先用DTO对象来承接前端传来的所有数据,从DTO对象拷贝出Dish对象,用于进行菜品表的更新。
而对于口味的修改,做法是:先删除相对应口味数据,再从DTO对象获取口味数据,插入表中,但需要补充菜品id再插入。
@Override
public void updateWithFlavor(DishDTO dishDTO) {
//修改菜品基本信息
Dish dish = new Dish();
BeanUtils.copyProperties(dishDTO,dish);
dishMapper.update(dish);
//修改口味数据
//先删除,再修改
dishFlavorMapper.deleteByDishId(dishDTO.getId());
List<DishFlavor> flavors = dishDTO.getFlavors();
if (flavors != null && flavors.size() > 0) {
for (DishFlavor flavor : flavors) {
flavor.setDishId(dishDTO.getId());
}
//向口味表插入n条数据
dishFlavorMapper.insertBatch(flavors);
}
}
这里重点关注,我们必须先删除旧数据,再插入新数据才行。