苍穹外卖 —— 文件上传和菜品的CRUD
一、前言
上一节将员工的CRUD做出来了,同时由于步骤几乎相同,对于分类的Controller,我们直接导入,就不重复书写了,接下来就要做菜品的CRUD了,这里会使用到阿里云OSS来存储文件(图片),同时菜品有不同的口味选择,所以需要两个表存储。
二、通用接口—文件上传
对于文件上传部分遗忘的,可以在这一篇文章中看到:
SpringMVC —— 响应和请求处理-CSDN博客
通用接口中将实现功能实现中公共的方法,这里我们先只添加文件上传的方法。
文件上传的原理就是通过阿里云OSS来实现云存储,这样可以方便后续菜品的图片上传的存储。
先看看文档怎么描述的,很显然,是通过请求体传入一个


依旧从上往下书写,先写通用接口的Controller,里面内含文件上传的方法:
/*** 通用接口*/
@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {//自动装配的是OssConfiguration中创建的含参数的aliOssUtil对象@Autowiredprivate AliOssUtil aliOssUtil;/*** 文件上传* @param file* @return*/@PostMapping("/upload")@ApiOperation("文件上传")public Result<String> upload(MultipartFile file) {log.info("文件上传:{}",file);try {//原始文件名String originalFilename = file.getOriginalFilename();//截取原始文件名的后缀  dfdfdf.pgnString extension = originalFilename.substring(originalFilename.lastIndexOf("."));//构建新文件名称String objectName = UUID.randomUUID().toString() + extension;//文件的请求路径String filePath = aliOssUtil.upload(file.getBytes(), objectName);return Result.success(filePath);} catch (IOException e) {log.error("文件上传失败:{}",e);}return Result.error(MessageConstant.UPLOAD_FAILED);}
}对于这个方法,我们的目的是通过工具类将指定文件上传到阿里云,我们从请求体中接收一个MultipartFile(二进制的文件类型参数),若上传成功,最后将返回一个文件路径的字符串到data,若上传失败,将返回报错结果集到msg。
这里对于文件名是进行了处理的,使用的是UUID来对文件进行随机命名(结合多种元素如时间戳、随机数等),但是由于我们依旧需要扩展名,所以要先将后缀分离出来,然后将文件名部分处理,最后拼接在一起。
最终得到类似的文件名:

OSS的工具类如下,这是基于阿里云官网给出的Java文档进行封装的,还是比较简单的:
@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/ObjectNameStringBuilder stringBuilder = new StringBuilder("https://");stringBuilder.append(bucketName).append(".").append(endpoint).append("/").append(objectName);log.info("文件上传到:{}", stringBuilder.toString());return stringBuilder.toString();}
}OSS的自动装配的配置类如下,目的是创建aliOssUtil的bean,便于在接口中自动装配:
/*** 用于创建AliOssUtil对象*/
@Configuration
@Slf4j
public class OssConfiguration {@Bean@ConditionalOnMissingBean//只要没有这个Bean就创建public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties){log.info("开始创建阿里云文件上传工具对象{}",aliOssProperties);return new AliOssUtil(aliOssProperties.getEndpoint(),aliOssProperties.getAccessKeyId(),aliOssProperties.getAccessKeySecret(),aliOssProperties.getBucketName());}
}三、菜品的CRUD
1.新增菜品
(1)文档
老样子,先看文档:

(2)Controller
可以看到这里需要接收请求体中的参数,所以直接就能想到用DTO接收,并且用@RequestBody标记,所以很容易可以写出:
/*** 菜品管理*/
@RestController
@RequestMapping("/admin/dish")
@Api(tags = "菜品相关接口")
@Slf4j
public class DishController {@Autowiredprivate DishService dishService;/*** 新增菜品* @param dishDTO* @return*/@PostMapping()@ApiOperation("新增菜品")public Result save(@RequestBody DishDTO dishDTO){log.info("新增菜品:{}",dishDTO);dishService.saveWithFlavor(dishDTO);return Result.success();}
}DTO如下:
@Data
public class DishDTO implements Serializable {private Long id;//菜品名称private String name;//菜品分类idprivate Long categoryId;//菜品价格private BigDecimal price;//图片private String image;//描述信息private String description;//0 停售 1 起售private Integer status;//口味private List<DishFlavor> flavors = new ArrayList<>();}值得注意的是,这里我们使用了一个数组集合来储存口味选项,因为一个菜品的口味可能有很多个,所以自然的,我们需要一个新的表来存储这个数据,这个表中由dish_id来作为逻辑外键,与dish表的主键进行关联(这样就知道哪几个口味属于哪一个菜品了):

同时dish也需要一个表来存储:

(3)Service层
接下来看Service层:
接口:
public interface DishService {/*** 新增菜品* @param dishDTO*/public void saveWithFlavor(DishDTO dishDTO);
}实现类:
@Service
@Slf4j
public class DishServiceImp implements DishService {@Autowiredprivate DishMapper dishMapper;@Autowiredprivate DishFlavorMapper dishFlavorMapper;@Override@Transactionalpublic void saveWithFlavor(DishDTO dishDTO) {Dish dish = new Dish();BeanUtils.copyProperties(dishDTO, dish);dishMapper.insert(dish);//获取Insert语句生成的主键值Long dishId = dish.getId();//向口味表中插入n条数据List<DishFlavor> flavors = dishDTO.getFlavors();if (flavors != null && flavors.size() > 0){flavors.forEach(dishFlavor -> {dishFlavor.setDishId(dishId);});//向口味表插入n条数据dishFlavorMapper.insertBatch(flavors);}}
}这里需要注意了,这个与员工的新增就不太一样了,首先先插入dish到菜品表中,这一点是一样的。
但是这里我们需要额外处理口味表:由于是用一个数组集合存储口味的,所以这里需要遍历口味表来将每个口味对应的dish_id设置为当前插入的菜品的id(逻辑外键),最终才将这些口味插入到口味表中去。
对应关系可以看看下图:

(4)持久层
两个表的mapper如下:
@Mapper
public interface DishFlavorMapper {/*** 批量插入口味数据* @param flavors*/void insertBatch(List<DishFlavor> flavors);
}@Mapper
public interface DishMapper {/*** 根据分类id查询菜品数量* @param categoryId* @return*/@Select("select count(id) from dish where category_id = #{categoryId}")Integer countByCategoryId(Long categoryId);/*** 新增菜品和对应口味* @param dish*/@AutoFill(value = OperationType.INSERT)void insert(Dish dish);
}由于新增操作涉及插入的变量较多,我们就不使用注解了,这里使用xml来配置:
<mapper namespace="com.sky.mapper.DishFlavorMapper"><insert id="insertBatch" >insert into dish_flavor(dish_id, name, value) VALUES<foreach collection="flavors" item="df" separator=",">(#{df.dishId},#{df.name},#{df.value})</foreach></insert>
</mapper>这里是用了动态sql语句的,将flavors集合遍历,插入表中(先前已经在Service层中处理了逻辑外键问题了)
<mapper namespace="com.sky.mapper.DishMapper"><insert id="insert" useGeneratedKeys="true" keyProperty="id">insert into dish(name, category_id, price, image, description, create_time, update_time, create_user, update_user,status)VALUES(#{name},#{categoryId},#{price},#{image},#{description},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})</insert>
</mapper>这里有两个参数比较陌生:
1. useGeneratedKeys="true":
作用:告知 MyBatis,当前插入操作的主键是由数据库自动生成的(例如 MySQL 的 AUTO_INCREMENT)
2. keyProperty="id":
作用:指定将数据库生成的主键值,赋值到 Java 对象的哪个属性上。
2.分页查询菜品
分页查询依旧需要使用到PageHelper。
(1)文档
先观察文档

很容易发现分页查询是用的Query参数,这就代表需要用到分页插件了,返回值是查询到的内容。
(2)Controller
有了员工的查询经验,这里很容易写出菜品的分页查询,这里由于使用到了分页插件,所以需要专门创建一个DTO来存储数据,Controller内容很简单,先日志,然后调用Service层,最后返回结果集,由于我们需要分好了页的结果,所以这里传到结果集中的是pageResult对象。
 /*** 菜品分页查询* @param dishPageQueryDTO* @return*/@GetMapping("/page")@ApiOperation("菜品分页查询")public Result<PageResult> page(DishPageQueryDTO dishPageQueryDTO) {log.info("菜品分页查询:{}", dishPageQueryDTO);PageResult pageResult = dishService.pageQuery(dishPageQueryDTO);return Result.success(pageResult);}DTO设计如下:
@Data
public class DishPageQueryDTO implements Serializable {private int page;private int pageSize;private String name;//分类idprivate Integer categoryId;//状态 0表示禁用 1表示启用private Integer status;}(3)Service层
接口:
/*** 菜品分页查询* @param dishPageQueryDTO* @return*/PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO);实现类:实现类中调用了PageHelper插件,传入DTO,这个DTO其实是前端发送过来的请求体,这里面包含了很多参数,包括了页码和每页记录数(所以这个DTO也与普通的DTO不一样),这将作为startPage方法传入的参数用于开启分页。
然后我们需要调用持久层获取查询结果,结果是用VO封装的(VO是前端展示数据,DTO是前端请求后端的),最终我们返回的VO结果集还需要封装到我们自己创建的PageResult中去,然后传给Controller。
其实这里可以理解为当我们按下下一页按钮时,前端就会重新发出一个请求,这时新DTO的参数就会改变成当前页的,于是开启分页时的参数也改变了,当然从持久层拿出的结果VO也会变,于是封装VO的结果集也变了,我们自己封装结果集的PageResult当然也会变,最后的结果就是在Controller中传回的响应结果也变了(响应回去的data中的数据),于是响应回前端的数据就变成当前页的了。
 /*** 菜品分页查询** @param dishPageQueryDTO* @return*/@Overridepublic PageResult pageQuery(DishPageQueryDTO dishPageQueryDTO) {PageHelper.startPage(dishPageQueryDTO.getPage(), dishPageQueryDTO.getPageSize());Page<DishVO> page = dishMapper.pageQuery(dishPageQueryDTO);return new PageResult(page.getTotal(), page.getResult());}我们自己封装的分页查询结果集如下:
/*** 封装分页查询结果*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult implements Serializable {private long total; //总记录数private List records; //当前页数据集合}(4)持久层
前面也提到了,持久层在分页中的作用就是拿出VO结果,我们要清晰的知道目的,不然会被各种封装绕晕。
Mapper:由于使用到动态语句,所以这里还是使用xml文件配置。
 /*** 菜品分页查询* @param dishPageQueryDTO* @return*/Page<DishVO> pageQuery(DishPageQueryDTO dishPageQueryDTO);映射文件:
这里使用的语句就是外连接语句了。
可以到这篇文章中复习一下:
MYSQL —— 约束和多表查询-CSDN博客
这里的sql语句很复杂,先要搞清楚目的,再看这条语句就会觉得豁然开朗了。
这里的目的是:通过动态条件查询 dish 表的菜品信息,并关联(通过id关联) category 表获取菜品对应的分类名称(通过外连接),最终将结果封装到 DishVO 实体类中,支持按名称模糊查询、按分类 ID 筛选、按状态筛选,并按创建时间倒序排列(最新创建的菜品在前)
 <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 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>3.批量删除菜品
(1)文档
先看文档:

这里请求的参数是ids,而且是String类型的,这样我们很不好处理,好在Spring很智能,可以将这个请求参数自动转化为一个集合,所以这里我们必然会使用到@RequestParam注解,返回值没有要求,就是返回个成功结果集就行了。
(2)Controller
    /*** 菜品批量删除* @param ids* @return*/@DeleteMapping@ApiOperation("菜品批量删除")public Result delete(@RequestParam List<Long> ids){log.info("菜品批量删除:{}",ids);dishService.deleteBatch(ids);return Result.success();}没什么好说的,用了刚刚的注解,将请求参数转换成了集合。
(3)Service层
接口:
     /*** 菜品批量删除* @param ids*/void deleteBatch(List<Long> ids);实现类:
这里要说一下,我们的删除和批量删除是做到一起的,因为批量删除完全可以完成删除的功能,完全可以复用。
来看具体的要求:起售的菜品肯定不能删,和套餐关联的菜品肯定也不能删,条件都满足,就可以删,所以我们采用了以下的逻辑:
遍历前端传来的集合(转换后),每次循环都从持久层拿一个对应id的对象,判断起售状态,不满足就抛异常,满足就继续判断是否被套餐关联。
然后去套餐表中查是否有这个id,如果有就抛异常,没有就进行删除操作。
删除操作是需要循环的,确保每个id对应的菜品都被删除。
     /*** 菜品批量删除** @param ids*/@Override@Transactionalpublic void deleteBatch(List<Long> ids) {//判断当前菜品是否能够删除---是否存在起售中的菜品for (Long id : ids) {Dish dish = dishMapper.getById(id);if (dish.getStatus() == StatusConstant.ENABLE) {//处于起售,不能删除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);}  }(4)持久层
下面两个简单,直接上注解,一个是用id查菜品,一个是用id删菜品。
    /*** 根据主键查询菜品* @param id* @return*/@Select("select * from dish where id = #{id}")Dish getById(Long id);/*** 根据主键删除菜品数据* @param id*/@Delete("delete from dish where id = #{id}")void deleteById(Long id);但是从套餐中查id就不能直接用注解了,因为需要动态sql语句,我们需要遍历套餐表,去查找在中间表中,是否对应的菜品id有对应的套餐id。
/*** 根据id查询对应套餐id* @param dishIds* @return*///select setmeal_id from setmeal dish where dish_id in (1,2,3,4)List<Long> getSetmealIdsByDishIds(List<Long> dishIds);目的:通过传入的多个菜品 ID(dishIds),查询出所有包含这些菜品的套餐 ID(setmeal_id),即找出哪些套餐关联了指定的菜品。
<mapper namespace="com.sky.mapper.SetmealDishMapper"><select id="getSetmealIdsByDishIds" resultType="java.lang.Long">select setmeal_id from setmeal_dish where dish_id in<foreach collection="dishIds" item="dishId" separator="," open="(" close=")">#{dishId}</foreach></select></mapper>这三个表关系如图:

(5)优化
最后一步删除操作是可以优化的,将循环在数据库中进行,节省从Java到数据库中消耗的时间。
可以优化如下:
        //优化//根据菜品id集合批量删除菜品数据//sql: delete from dish where id in (?,?,?)dishMapper.deleteByIds(ids);//根据菜品id集合批量删除关联的口味数据//sql: delete from dish_flavor where dish_id in (?,?,?)dishFlavorMapper.deleteByDishIds(ids);分别在菜品和口味的Mapper中添加优化的查找方式(在sql中动态循环):
/*** 根据菜品id集合批量删除菜品* @param ids*/void deleteByIds(List<Long> ids);<delete id="deleteByIds">delete from dish where id in<foreach collection="ids" open="(" close=")" separator="," item="id">#{id}</foreach></delete>/*** 根据菜品id集合批量删除关联的口味数据* @param dishids*/void deleteByDishIds(List<Long> dishids);
 <delete id="deleteByDishIds" >delete from dish_flavor where dish_id<foreach collection="dishIds" open="(" close=")" separator="," item="dishId">#{dishId}</foreach></delete>4.修改菜品
没有什么要注意的,别忘记修改时选项要回显就行(相当于又是一个接口,查询接口)。
(1)文档

修改操作注意需要做到数据的回显,所以可以看到请求体中向后端带来了全部参数,最终只需要返回成功结果集即可。
(2)Controller
用DTO接收请求体中的数据,然后调用Service层。
 /*** 修改菜品* @param dishDTO* @return*/@PutMapping@ApiOperation("修改菜品")public Result update(@RequestBody DishDTO dishDTO){log.info("修改菜品");dishService.updateWithFlavor(dishDTO);return Result.success();}(3)Service层
接口:
/*** 根据id修改菜品和口味信息* @param dishDTO*/void updateWithFlavor(DishDTO dishDTO);实现类:
/*** 修改信息* @param dishDTO*/@Overridepublic 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) {flavors.forEach(dishFlavor -> {dishFlavor.setDishId(dishDTO.getId());});//向口味表插入n条数据dishFlavorMapper.insertBatch(flavors);}}(4)持久层
Mapper:
    /*** 根据id动态修改菜品* @param dish*/@AutoFill(value = OperationType.UPDATE)void update(Dish dish);映射文件:
 <update id="update">update dish<set><if test="name != null">name = #{name},</if><if test="categoryId != null">category_id = #{categoryId},</if><if test="price != null">price = #{price},</if><if test="image != null">image = #{image},</if><if test="description != null">description = #{description},</if><if test="status != null">status = #{status},</if><if test="updateTime != null">update_time = #{updateTime},</if><if test="updateUser != null">update_user = #{updateUser},</if></set>where id = #{id}</update>(5)回显
原理是点下修改按钮,就会跳转页面,同时向后端发起请求参数id,后端根据id查询菜品和口味,最后从后端返回VO集合(由于是要向前端展示的,所以用的VO)。
虽然也算一个完整接口,但是很简单,所以给出代码即可。
 /*** 根据id查询菜品* @param id* @return*/@GetMapping("/{id}")public Result<DishVO> getById(@PathVariable Long id){log.info("根据id查询菜品:{}",id);DishVO dishVO = dishService.getByIdWithFlavor(id);return Result.success(dishVO);} /*** 根据id查询菜品和对应口味* @param id* @return*/DishVO getByIdWithFlavor(Long id); /*** 根据id查询菜品和对应口味* @param id* @return*/@Overridepublic DishVO getByIdWithFlavor(Long id) {//根据id查询菜品数据Dish dish = dishMapper.getById(id);//根据菜品id查询口味数据List<DishFlavor> dishFlavors = dishFlavorMapper.getByDishId(id);//将查询到的数据封装到VODishVO dishVO = new DishVO();BeanUtils.copyProperties(dish,dishVO);dishVO.setFlavors(dishFlavors);return dishVO;} /*** 根据主键查询菜品* @param id* @return*/@Select("select * from dish where id = #{id}")Dish getById(Long id);