**谷粒随享** ## 第1章 环境搭建、专辑管理 **学习目标:** - 了解听书项目业务背景 - 搭建项目环境 - 虚拟机环境(Docker软件) - 数据库环境 - Maven项目环境(基础代码) - 小程序环境 - 专辑管理 - MinIO分布式文件存储服务 随堂流程图:https://kdocs.cn/join/gjxmegv?f=101 项目地址:https://gitee.com/lvhonlgong/tingshu-241229.git git更新命令: ```sh git pull origin master ``` 如果修改过代码导致本地跟远端不一致,强制更新(远端覆盖本地) ```shell git fetch --all git reset --hard origin/master git pull ``` # 1、谷粒随享 ## 1.1 项目背景 随着智能手机和高速互联网的普及,人们开始寻求更便捷的方式来获取信息和娱乐。有声书的出现使得人们可以在旅途中、跑步时、做家务时等各种场景下,以更加灵活的方式享受阅读。 在过去,有声书主要是由**专业的演员朗读**,制作成录音带或CD。但随着数字化媒体的发展,听书软件应运而生,为用户提供了更多选择,包括自助出版的有声书和多样化的内容。 意义: 1. 便捷性:听书软件使得阅读不再局限于纸质书籍,用户可以通过手机等设备在任何时间、任何地点收听有声书,节省了携带实体书的麻烦。 2. 多样化内容:听书软件提供了广泛的有声书选择,涵盖了各种类型的图书、小说、杂志、教育内容等。这样的多样性使得用户能够根据个人兴趣和需求选择内容。 3. 阅读体验:通过专业的朗读演员和音效制作,听书软件可以提供更加生动、有趣的阅读体验,有助于吸引更多读者,尤其是那些不太喜欢阅读纸质书籍的人。 5. 辅助功能:听书软件通常还具备一些辅助功能,如调整朗读速度、书签功能、字幕显示等,有助于提高可访问性,使得视力受损或其他障碍的用户也能轻松阅读。 6. 支持作家和内容创作者:听书软件为作家和内容创作者提供了另一种传播作品的渠道,有助于扩大影响力和读者群。 7. 学习工具:听书软件也可以用作学习工具,提供学术教材、外语学习材料等,帮助用户在学习过程中更好地理解和吸收知识。 总的来说,听书软件的开发推动了阅读体验的数字化和个性化,为用户提供了更加便捷、多样化的阅读方式,也促进了作家和内容创作者的创作和传播。 有声书平台包含三角色: **内容创作者:** 将书籍录制成有声书(音频)制作成专辑,在听书平台上发布专辑,可以达到盈利目的。 **商家运营者:**运营听书平台上所有的专辑,购买专辑版权。对专辑进行审核、发布、订单管理、支付管理。消费者消费(购买VIP会员,购买专辑、购买声音)可以达到盈利目的。 **用户:**收听平台上优质资源(**知识付费**),购买会员,购买专辑,购买声音 ## 1.2 项目技术栈 - **SpringBoot**:简化Spring应用的初始搭建以及开发过程 - **SpringCloud**:基于Spring Boot实现的云原生应用开发工具,SpringCloud使用的技术:(Spring Cloud Gateway、Spring Cloud Feign、Spring Cloud Nacos(LoadBalancer)、Sentinel等) - MyBatis-Plus:持久层框架(简化持久层开发)基于Mybatis - Redis:内存做缓存;分布式锁;排行榜;布隆过滤器 - **Redisson**:基于redis的Java驻内存数据网格 - 框架;基于redis提供分布式服务跟对象 - **MongoDB**: 分布式文档型数据库,存储低价值数据。 - RabbitMQ:消息中间件;中大型分布式项目是标配;分布式事务最终一致性 - **ElasticSearch**+Kibana+Logstash 全文检索服务器+可视化数据监控:检索 - ThreadPoolExecutor+**CompletableFuture**:线程池来实现异步操作,提高效率 - **Xxl-Job**: 分布式定时任务调用中心 - Swagger/Knife4J/**YAPI**:Api接口文档工具 - MinIO(私有化对象存储集群):分布式文件存储 类似于OSS(公有) - 在线支付平台:**微信支付** - MySQL:关系型数据库 - Hutool:Java工具类库 - Lombok: 实体类的中get/set 生成的jar包 - **Natapp**:内网穿透工具 - Docker:容器化技术; 生产环境(运维人员);快速搭建环境 - Git:代码管理工具;git使用,拉代码、提交、推送、合并、冲突解决 - **Canal**:阿里开源增量订阅组件,数据增量同步 - **Seata**:阿里/Apache开源分布式事务解决方案 前端技术栈 - UniApp - Vue3全家桶 - TypeScript - GraceUI - UniUI - uniapp-axios-adapter ## 1.3 项目架构图 ![](./assets/jiagoutu.png) ## 1.4 环境搭建 1. 参考听书软件环境安装.md 2. 导入听书初始化项目资料中的**tingshu-parent**项目导入idea开发工具中即可! ### 1.4.1 虚拟机环境 第一步: ![img](assets/wps5.jpg) 第二步:改**NAT模式**的子网IP:**192.168.200.0** ![img](assets/wps6.jpg) 第三步:应用确定 第四步:启动虚拟机 ![img](assets/wps7.jpg) 第五步:登录虚拟机 ```properties IP:192.168.200.6 登录用户:root 登录密码:root ``` ### 1.4.2 虚拟机容器列表 目前在虚拟机中安装以下容器服务都是开机自启动! | 名 | URL | 账号密码 | | :-----------: | :------------------------------ | :----------------------: | | Portainer | http://192.168.200.6:19000 | admin/admin1234567 | | MySQL | 192.168.200.6:3306 | root/root | | Redis | 192.168.200.6:6379 | | | Elasticsearch | http://192.168.200.6:9200 | elastic/111111 | | Kibana | http://192.168.200.6:5601 | elastic/111111 | | Logstash | 收集日志的后台进程,无需访问 | | | Zipkin | http://192.168.200.6:9411 | | | Nacos | http://192.168.200.6:8848/nacos | nacos/nacos | | MinIO | http://192.168.200.6:9001 | admin/admin123456 | | YAPI | http://192.168.200.6:3000 | admin@admin.com/ymfe.org | | MongoDB | 192.168.200.6:27017 | | | RabbitMQ | http://192.168.200.6:15672/ | admin/admin | Tips:如果发现某些容器启动失败(说明该容器依赖其他容器,确保被依赖容器先正常启动),重启虚拟机 Nacos容器依赖MySQL容器-先启动MySQL容器 Kibana容器依赖ElasticSearch容器:先启动ES容 YAPI容器依赖MongoDB容器:先启动MongoDB 如果发现Docker服务重启/启动报错。将Docker服务重启,所有容器开机自启 ``` systemctl restart docker ``` ### 1.4.3 小程序工程 1. 找到配套资料中**mp-weixin-微信小程序.zip**解压 ![image-20240401103203674](assets/image-20240401103203674.png) 2. 配套资料\02-软件\找到安装微信开发者工具 ![image-20231013210033403](assets/image-20231013210033403.png) 3. 每个同学注册申请微信小程序测试号(微信登录会使用到)测试账号申请入口https://mp.weixin.qq.com/wxamp/sandbox?doc=1 ``` appId:测试号应用ID appSecret:测试号对应秘钥 ``` 4. 在微信开发者工具中导入,导入选择信任项目,**注意:这里填写自己申请测试号应用ID** ![image-20231013205325436](assets/image-20231013205325436.png) 5. 小程序默认访问的后端网关地址为本地8500端口 ![image-20231013205735725](assets/image-20231013205735725.png) ### 1.4.4 导入初始化工程 1. 听书/配套资料/初始后台代码/**tingshu-parent.zip**压缩包解压 2. 在Idea中导入 ![image-20231013202902427](assets/image-20231013202902427.png) # 2、专辑管理添加 功能入口:运行app项目-->我的-->创作中心-->专辑-->点击 **+** 添加专辑。提供给**内容创作者**/**平台运营人员**。使用(该功能必须登录才能访问),专辑经过审核(文字+封面)机制,审核通过内容创作者用户录制声音(音频文件-声音管理)新增专辑,将声音关联到专辑下。其他的普通用户(网民)可以在APP/小程序中进行获取内容资源(有声书等资源) 主要功能如下: 1. **先获取到专辑分类** 2. **查询专辑标签** 3. **文件上传** 4. **保存专辑** ![保存专辑](assets/保存专辑.gif) ## 2.1 查看分类数据 **需求**:在保存专辑需要为新增专辑关联分类,三级分类数据需要采用列表展示 ![image-20231001113440333](assets/image-20231001113440333.png) 创建视图: ```sql # 需求:基于视图对象封装所有1,2,3级分类数据 视图中包含1,2,3级分类ID及名称 为视图中记录设置三级分类ID作为视图主键 # 视图语法 :create [or replace] view 视图名称 as SQL; 如果变更视图中数据 变更SQL后重新再次执行创建即可 /** 编写SQL: 1.根据业务需求确定查询业务数据来自于哪些表 2.确定使用哪种关联查询方式(内连接,外连接) 3.确定表关联条件 4.判断是否需要进行筛选数据where,是否需要进行排序(order by),是否需要进行分组(group by),是否需要分组后过滤(having),limit等 5.通过查看执行计划,设计合理索引确保SQL执行效率,要求至少达到Range级别 */ # 1.根据业务需求确定查询业务数据来自于哪些表:base_category1,base_category2,base_category3 # 2.确定使用哪种关联查询方式:选择内,外均可 # 3.确定表关联条件:二及分类表1级分类ID关联二级分类表主键;三级分类表中2级分类ID关联2级分类表主键 select * from base_category1 bc1 inner join base_category2 bc2 on bc2.category1_id = bc1.id inner join base_category3 bc3 on bc3.category2_id = bc2.id; select bc3.id, bc1.id, bc1.name, bc2.id, bc2.name, bc3.id, bc3.name from base_category1 bc1 inner join base_category2 bc2 on bc2.category1_id = bc1.id inner join base_category3 bc3 on bc3.category2_id = bc2.id where bc1.is_deleted = 0 and bc2.is_deleted = 0; # 视图记录会封装到BaseCategoryView对象中,需要对对不同分类ID,名称起别名 select bc3.id, bc1.id category1_id, bc1.name category1_name, bc2.id category2_id, bc2.name category2_name, bc3.id category3_id, bc3.name category3_name, bc3.create_time, bc3.update_time, bc3.is_deleted from base_category1 bc1 inner join base_category2 bc2 on bc2.category1_id = bc1.id inner join base_category3 bc3 on bc3.category2_id = bc2.id where bc1.is_deleted = 0 and bc2.is_deleted = 0; # 5.通过查看执行计划,设计合理索引确保SQL执行效率,要求至少达到Range级别 explain select bc3.id, bc1.id category1_id, bc1.name category1_name, bc2.id category2_id, bc2.name category2_name, bc3.id category3_id, bc3.name category3_name, bc3.create_time, bc3.update_time, bc3.is_deleted from base_category1 bc1 inner join base_category2 bc2 on bc2.category1_id = bc1.id inner join base_category3 bc3 on bc3.category2_id = bc2.id where bc1.is_deleted = 0 and bc2.is_deleted = 0; #6. 创建分类视图对象 create or replace view base_category_view as select bc3.id, bc1.id category1_id, bc1.name category1_name, bc2.id category2_id, bc2.name category2_name, bc3.id category3_id, bc3.name category3_name, bc3.create_time, bc3.update_time, bc3.is_deleted from base_category1 bc1 inner join base_category2 bc2 on bc2.category1_id = bc1.id inner join base_category3 bc3 on bc3.category2_id = bc2.id where bc1.is_deleted = 0 and bc2.is_deleted = 0; ``` 涉及到的视图对象: **base_category_view** ,在这张视图中存储了所有的分类数据。展示分类数据的格式如下: ```json [ { "categoryName":"音乐", #一级分类名称 "categoryId":1, #一级分类ID "categoryChild":[ #当前一级分类包含的二级分类集合 { "categoryName":"音乐音效", #二级分类名称 "categoryId":101, #二级分类ID "categoryChild":[ #当前二级分类包含的三级分类集合 { "categoryName": "催眠音乐", "categoryId": 1001 }, { "categoryName": "放松音乐", "categoryId": 1002 }, { "categoryName": "提神音乐", "categoryId": 1003 } ] } ] }, { "categoryName":"有声书", "categoryId":2, "categoryChild":[ { "categoryName":"男频小说", "categoryId":104, "categoryChild":[ { "categoryName":"军事小说", "categoryId":1009 } ] } ] } ] ``` > YAP接口地址:http://192.168.200.6:3000/project/11/interface/api/11 ### 2.1.1 控制器 在`service-album`模块中**BaseCategoryApiController**控制器编写 ```java package com.atguigu.tingshu.album.api; import com.alibaba.fastjson.JSONObject; import com.atguigu.tingshu.album.service.BaseCategoryService; import com.atguigu.tingshu.common.result.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; 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.Arrays; import java.util.List; @Tag(name = "分类管理") @RestController @RequestMapping(value = "/api/album") @SuppressWarnings({"all"}) public class BaseCategoryApiController { @Autowired private BaseCategoryService baseCategoryService; /** * 查询所有分类(1、2、3级分类) * * @return 业务数据:[{"categoryId":1,"categoryName":"分类",categoryChild:[..]},{其他1级分类}] */ @Operation(summary = "查询所有分类(1、2、3级分类)") @GetMapping("/category/getBaseCategoryList") public Result> getBaseCategoryList() { List list = baseCategoryService.getBaseCategoryList(); return Result.ok(list); } } ``` ### 2.2.2 业务层 接口与实现类 ```java package com.atguigu.tingshu.album.service; import com.alibaba.fastjson.JSONObject; import com.atguigu.tingshu.model.album.BaseCategory1; import com.baomidou.mybatisplus.extension.service.IService; import java.util.List; public interface BaseCategoryService extends IService { /** * 查询所有分类(1、2、3级分类) * * @return 业务数据:[{"categoryId":1,"categoryName":"分类",categoryChild:[..]},{其他1级分类}] */ List getBaseCategoryList(); } ``` ```java package com.atguigu.tingshu.album.service.impl; import com.alibaba.fastjson.JSONObject; import com.atguigu.tingshu.album.mapper.BaseCategory1Mapper; import com.atguigu.tingshu.album.mapper.BaseCategory2Mapper; import com.atguigu.tingshu.album.mapper.BaseCategory3Mapper; import com.atguigu.tingshu.album.mapper.BaseCategoryViewMapper; import com.atguigu.tingshu.album.service.BaseCategoryService; import com.atguigu.tingshu.model.album.BaseCategory1; import com.atguigu.tingshu.model.album.BaseCategoryView; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Service @SuppressWarnings({"all"}) public class BaseCategoryServiceImpl extends ServiceImpl implements BaseCategoryService { @Autowired private BaseCategory1Mapper baseCategory1Mapper; @Autowired private BaseCategory2Mapper baseCategory2Mapper; @Autowired private BaseCategory3Mapper baseCategory3Mapper; @Autowired private BaseCategoryViewMapper baseCategoryViewMapper; /** * 查询所有分类(1、2、3级分类) * * @return 业务数据:[{"categoryId":1,"categoryName":"分类",categoryChild:[..]},{其他1级分类}] */ @Override public List getBaseCategoryList() { //1.创建响应结果集合对象-用于封装所有一级分类对象 List returnList = new ArrayList<>(); //2.查询所有分类数据-查询视图即可 共计401条记录 List allCategoryList = baseCategoryViewMapper.selectList(null); //3.处理一级分类数据 //3.1 对所有分类集合列表进行分组按照1级分类ID进行分组 得到Map<分组ID,一级分类列表> Map> category1Map = allCategoryList.stream() .collect(Collectors.groupingBy(BaseCategoryView::getCategory1Id)); for (Map.Entry> entry1 : category1Map.entrySet()) { //3.2 封装一级分类对象 JSONObject jsonObject1 = new JSONObject(); //3.2.1 封装1级分类ID Long category1Id = entry1.getKey(); jsonObject1.put("categoryId", category1Id); //3.2.2 封装1级分类名称 String category1Name = entry1.getValue().get(0).getCategory1Name(); jsonObject1.put("categoryName", category1Name); //4. 处理二级分类数据 List jsonObject2List = new ArrayList<>(); //4.1 对"1级"分类集合按照二级分类ID进行分组 Map> category2Map = entry1.getValue() .stream().collect(Collectors.groupingBy(BaseCategoryView::getCategory2Id)); //4.2 遍历"2级"分类Map for (Map.Entry> entry2 : category2Map.entrySet()) { //4.3 封装二级分类对象 JSONObject jsonObject2 = new JSONObject(); //4.3.1 封装2级分类ID Long category2Id = entry2.getKey(); jsonObject2.put("categoryId", category2Id); //4.3.2 封装2级分类名称 String category2Name = entry2.getValue().get(0).getCategory2Name(); jsonObject2.put("categoryName", category2Name); //4.4 将2级分类对象放入二级分类集合中 jsonObject2List.add(jsonObject2); //5. 处理三级分类数据 List jsonObject3List = new ArrayList<>(); //5.1 对"2级"分类列表进行遍历 for (BaseCategoryView baseCategoryView : entry2.getValue()) { //5.2 封装三级分类JSONOBject对象 JSONObject jsonObject3 = new JSONObject(); //5.2.1 封装3级分类ID jsonObject3.put("categoryId", baseCategoryView.getCategory3Id()); //5.2.2 封装3级分类名称 jsonObject3.put("categoryName", baseCategoryView.getCategory3Name()); //5.3 将3级分类对象放入集合中 jsonObject3List.add(jsonObject3); } //5.4 将3级分类对象集合加入到二级分类对象"categoryChild"属性中 jsonObject2.put("categoryChild", jsonObject3List); } //4.5 将二级分类集合封装在一级分类对象中"categoryChild"属性中 jsonObject1.put("categoryChild", jsonObject2List); returnList.add(jsonObject1); } return returnList; } } ``` ## 2.2 专辑标签列表 ![](assets/image-20231001115629519.png) > YAPI接口地址: http://192.168.200.6:3000/project/11/interface/api/15 ### 2.2.1 控制器 在**BaseCategoryApiController控制器**中添加代码 ```java /** * 根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表 * * @param category1Id * @return */ @Operation(summary = "根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表") @GetMapping("/category/findAttribute/{category1Id}") public Result> getAttributesByCategory1Id(@PathVariable Long category1Id) { List list = baseCategoryService.getAttributesByCategory1Id(category1Id); return Result.ok(list); } ``` ### 2.2.2 业务层 **BaseCategoryService** ```java /** * 根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表 * * @param category1Id * @return */ List getAttributesByCategory1Id(Long category1Id); ``` **BaseCategoryServiceImpl** ```java @Autowired private BaseAttributeMapper baseAttributeMapper; /** * 根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表 * * @param category1Id * @return */ @Override public List getAttributesByCategory1Id(Long category1Id) { //1.获取持久层接口,调用持久层动态SQL return baseAttributeMapper.getAttributesByCategory1Id(category1Id); } ``` ### 2.2.3 持久层 在**BaseAttributeMapper**中添加方法 ```java package com.atguigu.tingshu.album.mapper; import com.atguigu.tingshu.model.album.BaseAttribute; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import java.util.List; @Mapper public interface BaseAttributeMapper extends BaseMapper { /** * 根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表 * @param category1Id * @return */ List getAttributesByCategory1Id(@Param("category1Id") Long category1Id); } ``` 动态SQL: ```sql #需求:根据一级分类Id获取分类属性以及属性值(标签名,标签值)列表 select ba.id, ba.attribute_name, bav.id base_attribute_value_id, bav.value_name, bav.attribute_id from base_attribute ba inner join base_attribute_value bav on bav.attribute_id = ba.id where ba.category1_id = ? and ba.is_deleted = 0; # #查询执行计划 explain select ba.id, ba.attribute_name, bav.id base_attribute_value_id, bav.value_name, bav.attribute_id from base_attribute ba inner join base_attribute_value bav on bav.attribute_id = ba.id where ba.category1_id = ? and ba.is_deleted = 0; ``` 在`resources `目录下创建**mapper目录**并添加配置文件**BaseAttributeMapper.xml** ```xml ``` ## 2.3 分布式文件存储 ### 2.3.1 MinIO 简介 ​ MinIO 是一个基于Apache License v3.0开源协议的对象存储服务。它兼容亚马逊S3云存储服务接口,非常适合于存储大容量非结构化的数据,例如图片、视频、日志文件、备份数据和容器/虚拟机镜像等,而一个对象文件可以是任意大小,从几kb到最大5T不等。 MinIO是一个非常轻量的服务,可以很简单的和其他应用的结合,类似 NodeJS, Redis 或者 MySQL。 [https://docs.min.io/](https://docs.min.io/ "https://docs.min.io/") 英文 **特点** - 高性能:作为高性能对象存储,在标准硬件条件下它能达到55GB/s的读、35GB/s的写速率 - 可扩容:不同MinIO集群可以组成联邦,并形成一个全局的命名空间,并跨越多个数据中心 - 云原生:容器化、基于K8S的编排、多租户支持 - Amazon S3兼容:Minio使用Amazon S3 v2 / v4 API。可以使用Minio SDK,Minio Client,AWS SDK和AWS CLI访问Minio服务器。 - 可对接后端存储: 除了Minio自己的文件系统,还支持DAS、 JBODs、NAS、Google云存储和Azure Blob存储。 - SDK支持: 基于Minio轻量的特点,它得到类似Java、Python或Go等语言 的sdk支持 - Lambda计算: Minio服务器通过其兼容AWS SNS / SQS的事件通知服务触发Lambda功能。支持的目标是消息队列,如Kafka,NATS,AMQP,MQTT,Webhooks以及Elasticsearch,Redis,Postgres和MySQL等数据库。 - 有操作页面 - 功能简单: 这一设计原则让MinIO不容易出错、更快启动 - 支持纠删码:MinIO使用纠删码、Checksum来防止硬件错误和静默数据污染。在最高冗余度配置下,即使丢失1/2的磁盘也能恢复数据! 存储机制 ​ Minio使用纠删码erasure code和校验和checksum。 即便丢失一半数量(N/2)的硬盘,仍然可以恢复数据。纠删码是一种恢复丢失和损坏数据的**数学算法**。 ### 2.3.2 Docker安装(已完成) > docker pull minio/minio > docker run \\ > \-p 9000:9000 \ > \-p 9001:9001 \\ > \--name minio \\ > \-d --restart=always \\ > \-e "MINIO\_ROOT\_USER=admin" \\ > \-e "MINIO\_ROOT\_PASSWORD=admin123456" \\ > \-v /home/data:/data \\ > \-v /home/config:/root/.minio \\ > minio/minio server /data --console-address ":9001" 浏览器访问:[http://192.168.200.6:9001/minio/login,如图:](http://IP:9001/minio/login,如图: "http://IP:9001/minio/login,如图:") ![](assets/atguigu001.png) 登录账户说明:安装时指定了**登录账号**:admin/admin123456 **注意**:文件上传时,需要调整一下linux 服务器的时间与windows 时间一致! ![](assets/timeerror.png) > ```shell > 第一步:安装ntp服务 > yum -y install ntp > 第二步:开启开机启动服务 > systemctl enable ntpd > 第三步:启动服务 Tips:联网正常前提下如果定时同步失败,先停止服务,再启动 > systemctl stop ntpd > systemctl start ntpd > 第四步:更改时区 > timedatectl set-timezone Asia/Shanghai > 第五步:启用ntp同步 > timedatectl set-ntp yes > 第六步:同步时间 > ntpq -p > ``` ### 2.3.3 专辑图片上传 MinIO-JavaAPI:https://min.io/docs/minio/linux/developers/java/API.html Tomcat默认限制上传文件大小:1MB 通过修改配置更改: ```yaml spring: servlet: multipart: max-file-size: 10MB #单个文件最大限制 max-request-size: 20MB #多个文件最大限制 ``` **业务需求**:在新增专辑前需要为专辑设置专辑封面,选中本机图片文件后将文件上传到MInIO,且返回上传后文件在线地址,方便用户进行预览。效果如下: ![专辑图片上传](assets/专辑图片上传.gif) > YAPI文档地址:http://192.168.200.6:3000/project/11/interface/api/13 ```java package com.atguigu.tingshu.album.api; import com.atguigu.tingshu.album.service.FileUploadService; import com.atguigu.tingshu.common.result.Result; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @Tag(name = "上传管理接口") @RestController @RequestMapping("api/album") public class FileUploadApiController { @Autowired private FileUploadService fileUploadService; /** * 图片(封面、头像)文件上传 * 前端提交文件参数名:file * * @param multipartFile * @return */ @Operation(summary = "图片(封面、头像)文件上传") @PostMapping("/fileUpload") public Result fileUpload(@RequestParam("file") MultipartFile multipartFile) { String fileUrl = fileUploadService.fileUpload(multipartFile); return Result.ok(fileUrl); } } ``` **配置MinIO客户端对象** ```java package com.atguigu.tingshu.album.config; import io.minio.MinioClient; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @ConfigurationProperties(prefix = "minio") //读取节点 @Data public class MinioConstantProperties { private String endpointUrl; private String accessKey; private String secreKey; private String bucketName; /** * 操作MInIO客户端对象 * @return */ @Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpointUrl) .credentials(accessKey, secreKey) .build(); } } ``` **FileUploadService** ```java package com.atguigu.tingshu.album.service; import org.springframework.web.multipart.MultipartFile; public interface FileUploadService { /** * 图片(封面、头像)文件上传 * 前端提交文件参数名:file * * @param multipartFile * @return */ String fileUpload(MultipartFile multipartFile); } ``` **FileUploadServiceImpl** ```java package com.atguigu.tingshu.album.service.impl; import cn.hutool.core.date.DateUtil; import cn.hutool.core.io.file.FileNameUtil; import cn.hutool.core.util.IdUtil; import com.atguigu.tingshu.album.config.MinioConstantProperties; import com.atguigu.tingshu.album.service.FileUploadService; import com.atguigu.tingshu.common.execption.GuiguException; import com.atguigu.tingshu.common.result.ResultCodeEnum; import io.minio.MinioClient; import io.minio.PutObjectArgs; import io.minio.errors.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; /** * @author: atguigu * @create: 2024-08-02 15:40 */ @Service public class FileUploadServiceImpl implements FileUploadService { @Autowired private MinioClient minioClient; @Autowired private MinioConstantProperties minioConstantProperties; /** * 图片(封面、头像)文件上传 * 前端提交文件参数名:file * * @param multipartFile * @return */ @Override public String fileUpload(MultipartFile multipartFile) { //1.业务校验验证图片内容格式是否合法 try { BufferedImage bufferedImage = ImageIO.read(multipartFile.getInputStream()); if (bufferedImage == null) { throw new GuiguException(400, "文件格式有误"); } //2.业务校验-验证图片大小合法 int width = bufferedImage.getWidth(); int height = bufferedImage.getHeight(); if (width > 900 || height > 900) { throw new GuiguException(400, "文件大小有误!"); } } catch (Exception e) { throw new GuiguException(400, "文件格式有误"); } try { //3.将文件上传到MInIO //3.1 生成带有文件夹目录文件名称 形式:/日期/随机文件名称.后缀 String folder = "/" + DateUtil.today(); String fileName = IdUtil.randomUUID(); String extName = FileNameUtil.extName(multipartFile.getOriginalFilename()); String objName = folder + "/" + fileName + "." + extName; //3.2 调用minio客户对象上传文件方法 String bucketName = minioConstantProperties.getBucketName(); minioClient.putObject( PutObjectArgs.builder().bucket(bucketName).object(objName).stream( multipartFile.getInputStream(), multipartFile.getSize(), -1) .contentType(multipartFile.getContentType()) .build()); //3.3 拼接上传文件在线路径地址 return minioConstantProperties.getEndpointUrl() + "/" + bucketName + objName; } catch (Exception e) { throw new GuiguException(500, "文件上传失败"); } } } ``` ## 2.4 保存专辑 设为私密:表示不发布的意思,后续可以通过这个按钮选项实现专辑的上架-下架操作 涉及的表: - **album_info** 专辑表 - 初始化userId 默认值1 为了后续能查到数据 - 并设置初始化状态为审核通过 - 如果是付费专辑则设置前五集为免费试看 - **album_attribute_value** 专辑属性值表 - 设置专辑Id - **album_stat** 专辑统计表 - 初始化统计数目为0 ### 2.4.1 控制层 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/17 **Tips**:新增的专辑需要将专辑关联到主播用户,但由于还未完成登录功能,故在获取用户工具类`AuthContextHolder`中**getUserId方法中**将获取用户ID的返回值写为固定。 ![image-20231002093707199](assets/image-20231002093707199.png) 在`service-album`模块中**AlbumInfoApiController** ```java package com.atguigu.tingshu.album.api; import com.atguigu.tingshu.album.service.AlbumInfoService; import com.atguigu.tingshu.common.result.Result; import com.atguigu.tingshu.common.util.AuthContextHolder; import com.atguigu.tingshu.model.album.AlbumInfo; import com.atguigu.tingshu.vo.album.AlbumInfoVo; import com.tencentcloudapi.cat.v20180409.models.AlarmInfo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; 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; @Tag(name = "专辑管理") @RestController @RequestMapping("api/album") @SuppressWarnings({"all"}) public class AlbumInfoApiController { @Autowired private AlbumInfoService albumInfoService; /** * TODO 该接口登录才可以访问 * 内容创作者或者平台运营人员-保存专辑 * * @param albumInfoVo 对象中属性需要进行合法验证,采用Validation框架进行校验 * @return */ @Operation(summary = "内容创作者或者平台运营人员-保存专辑") @PostMapping("/albumInfo/saveAlbumInfo") public Result saveAlbumInfo(@Validated @RequestBody AlbumInfoVo albumInfoVo) { //1.动态获取用户ID Long userId = AuthContextHolder.getUserId(); //2.调用业务逻辑完成新增 albumInfoService.saveAlbumInfo(albumInfoVo, userId); return Result.ok(); } } ``` ### 2.4.2 业务层 **AlbumInfoService接口** ```java package com.atguigu.tingshu.album.service; import com.atguigu.tingshu.model.album.AlbumInfo; import com.atguigu.tingshu.vo.album.AlbumInfoVo; import com.baomidou.mybatisplus.extension.service.IService; public interface AlbumInfoService extends IService { /** * 内容创作者或者平台运营人员-保存专辑 * @param albumInfoVo * @param userId */ void saveAlbumInfo(AlbumInfoVo albumInfoVo, Long userId); /** * 保存专辑统计信息 * @param albumId 专辑ID * @param statType 统计类型 * @param statNum 统计数值 0401-播放量 0402-订阅量 0403-购买量 0403-评论数' */ void saveAlbumInfoStat(Long albumId, String statType, int statNum); } ``` **AlbumInfoServiceImpl实现类** ```java package com.atguigu.tingshu.album.service.impl; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollectionUtil; import com.atguigu.tingshu.album.mapper.AlbumAttributeValueMapper; import com.atguigu.tingshu.album.mapper.AlbumInfoMapper; import com.atguigu.tingshu.album.mapper.AlbumStatMapper; import com.atguigu.tingshu.album.service.AlbumInfoService; import com.atguigu.tingshu.common.constant.SystemConstant; import com.atguigu.tingshu.model.album.AlbumAttributeValue; import com.atguigu.tingshu.model.album.AlbumInfo; import com.atguigu.tingshu.model.album.AlbumStat; import com.atguigu.tingshu.model.search.AlbumInfoIndex; import com.atguigu.tingshu.vo.album.AlbumAttributeValueVo; import com.atguigu.tingshu.vo.album.AlbumInfoVo; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Slf4j @Service @SuppressWarnings({"all"}) public class AlbumInfoServiceImpl extends ServiceImpl implements AlbumInfoService { @Autowired private AlbumInfoMapper albumInfoMapper; @Autowired private AlbumAttributeValueMapper albumAttributeValueMapper; @Autowired private AlbumStatMapper albumStatMapper; /** * 内容创作者或者平台运营人员-保存专辑 * * @param albumInfoVo * @param userId */ @Override @Transactional(rollbackFor = Exception.class) public void saveAlbumInfo(AlbumInfoVo albumInfoVo, Long userId) { //1.保存专辑信息 //1.1 将专辑VO转为PO对象 AlbumInfo albumInfo = BeanUtil.copyProperties(albumInfoVo, AlbumInfo.class); //1.2 为专辑属性赋值:用户ID,试听集数,审核状态 albumInfo.setUserId(userId); String payType = albumInfo.getPayType(); if (ALBUM_PAY_TYPE_VIPFREE.equals(payType) || ALBUM_PAY_TYPE_REQUIRE.equals(payType)) { //只需要对VIP免费或付费资源设置试听集 albumInfo.setTracksForFree(3); } albumInfo.setStatus(ALBUM_STATUS_NO_PASS); //1.3 保存专辑,得到专辑ID albumInfoMapper.insert(albumInfo); Long albumId = albumInfo.getId(); //2.保存专辑标签关系信息 //2.1 获取VO中提交专辑标签关系集合 List albumAttributeValueVoList = albumInfoVo.getAlbumAttributeValueVoList(); if (CollUtil.isNotEmpty(albumAttributeValueVoList)) { //2.2 为专辑标签关联专辑ID,"批量"新增专辑标签关系 for (AlbumAttributeValueVo albumAttributeValueVo : albumAttributeValueVoList) { AlbumAttributeValue albumAttributeValue = BeanUtil.copyProperties(albumAttributeValueVo, AlbumAttributeValue.class); albumAttributeValue.setAlbumId(albumId); albumAttributeValueMapper.insert(albumAttributeValue); } } //3.初始化专辑统计信息 this.saveAlbumInfoStat(albumId, ALBUM_STAT_PLAY, 0); this.saveAlbumInfoStat(albumId, ALBUM_STAT_SUBSCRIBE, 0); this.saveAlbumInfoStat(albumId, ALBUM_STAT_BUY, 0); this.saveAlbumInfoStat(albumId, ALBUM_STAT_COMMENT, 0); //4.TODO 对专辑中文本内容进行审核 & 索引库ES中新增记录 } /** * 保存专辑统计信息 * * @param albumId 专辑ID * @param statType 统计类型 * @param statNum 统计数值 0401-播放量 0402-订阅量 0403-购买量 0403-评论数' */ @Override public void saveAlbumInfoStat(Long albumId, String statType, int statNum) { AlbumStat albumStat = new AlbumStat(); albumStat.setAlbumId(albumId); albumStat.setStatType(statType); albumStat.setStatNum(statNum); albumStatMapper.insert(albumStat); } } ``` # 3、查看专辑列表 ​ 需求:创作者中心-->查询**当前登录用户**发布的专辑列表,每个专辑包含:专辑ID,专辑封面图片、专辑名称、专辑包含声音个数、创建时间、**播放量、购买量、订阅数、评论数量**等 ;根据专辑审核状态及任意关键字进行模糊查询。 ![专辑列表](assets/专辑列表.gif) > YAPI接口文档:http://192.168.200.6:3000/project/11/interface/api/19 ## 3.1 控制层 **AlbumInfoApiController控制器** 查询数据的时候,我们将页面渲染的数据封装到一个实体类中AlbumListVo,只需要返回这个类的集合即可! ```java /** * TODO 该接口登录才可以访问 * 分页条件查询当前登录用户发布专辑 * * @param page * @param limit * @param albumInfoQuery * @return */ @Operation(summary = "分页条件查询当前登录用户发布专辑") @PostMapping("/albumInfo/findUserAlbumPage/{page}/{limit}") public Result> findUserAlbumPage(@PathVariable Long page, @PathVariable Long limit, @RequestBody AlbumInfoQuery albumInfoQuery) { //1.获取当前用户登录ID Long userId = AuthContextHolder.getUserId(); albumInfoQuery.setUserId(userId); //2.控制层封装分页参数:页码、页大小 Page pageParam = new Page<>(page, limit); //3.调用业务逻辑层完成分页查询 封装:总记录数,总页数,当前页数据 pageParam = albumInfoService.findUserAlbumPage(pageParam, albumInfoQuery); return Result.ok(pageParam); } ``` ## 3.2 业务层 **AlbumInfoService接口** ```java /** * 分页条件查询当前登录用户发布专辑 * @param pageParam MP分页对象 * @param albumInfoQuery 查询条件 * @return */ Page findUserAlbumPage(Page pageParam, AlbumInfoQuery albumInfoQuery); ``` **AlbumInfoServiceImpl实现类** ```java /** * 分页条件查询当前登录用户发布专辑 * * @param pageParam MP分页对象 * @param albumInfoQuery 查询条件 * @return */ @Override public Page findUserAlbumPage(Page pageParam, AlbumInfoQuery albumInfoQuery) { return albumInfoMapper.findUserAlbumPage(pageParam, albumInfoQuery); } ``` ## 3.3 持久层 SQL演练: ```sql #需求:分页查询指定内容创作者专辑列表 包含专辑信息:id,封面,标题,声音数量 专辑信息信息:四项 #1.采用左外连接专辑表关联统计表查询 关联条件:统计表专辑ID关联专辑表ID 过滤条件:用户ID select * from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ?; #2.目的将四条统计信息封装到专辑列表AlbumListVo中四个统计信息属性中 关键问题:行传列,将四条统计记录转为四列统计信息 select ai.id albumId, ai.album_title, ai.cover_url, ai.include_track_count, ai.is_finished, ai.status, stat.stat_type, stat.stat_num from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ? and ai.is_deleted = 0; #3.对专辑ID进行分组 产生新问题:违背严格模式中only_full_group_by规则 要求:select中只能出现函数及分组字段,解决办法 去掉查询统计两列信息(否) select ai.id albumId, ai.album_title, ai.cover_url, ai.include_track_count, ai.is_finished, ai.status, stat.stat_type, stat.stat_num from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ? and ai.is_deleted = 0 group by ai.id; #4.使用函数进行传列 使用if函数 select if(1=1,'a','b'); select ai.id albumId, ai.album_title, ai.cover_url, ai.include_track_count, ai.is_finished, ai.status, max(if(stat.stat_type='0401', stat.stat_num, 0)) playStatNum, max(if(stat.stat_type='0402', stat.stat_num, 0)) subscribeStatNum, max(if(stat.stat_type='0403', stat.stat_num, 0)) buyStatNum, max(if(stat.stat_type='0404', stat.stat_num, 0)) commentStatNum from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ? and ai.is_deleted = 0 group by ai.id; select max(if(stat_type='0401', stat_num, 0)), max(if(stat_type='0402', stat_num, 0)), max(if(stat_type='0403', stat_num, 0)), max(if(stat_type='0404', stat_num, 0)) from album_stat where album_id = 2; #5.加入排序,逻辑删除判断 select ai.id albumId, ai.album_title, ai.cover_url, ai.include_track_count, ai.is_finished, ai.status, max(if(stat.stat_type='0401', stat.stat_num, 0)) playStatNum, max(if(stat.stat_type='0402', stat.stat_num, 0)) subscribeStatNum, max(if(stat.stat_type='0403', stat.stat_num, 0)) buyStatNum, max(if(stat.stat_type='0404', stat.stat_num, 0)) commentStatNum from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ? and ai.is_deleted = 0 group by ai.id order by ai.id desc limit 10; #limit start(起始位置偏移量),size(显示记录数) start=(页码-1)*页大小 size=固定页大小 #6.查看执行计划 explain select ai.id albumId, ai.album_title, ai.cover_url, ai.include_track_count, ai.is_finished, ai.status, max(if(stat.stat_type='0401', stat.stat_num, 0)) playStatNum, max(if(stat.stat_type='0402', stat.stat_num, 0)) subscribeStatNum, max(if(stat.stat_type='0403', stat.stat_num, 0)) buyStatNum, max(if(stat.stat_type='0404', stat.stat_num, 0)) commentStatNum from album_info ai left join album_stat stat on stat.album_id = ai.id where ai.user_id = ? and ai.is_deleted = 0 group by ai.id order by ai.id desc limit 10; ``` **AlbumInfoMapper ** ```java package com.atguigu.tingshu.album.mapper; import com.atguigu.tingshu.model.album.AlbumInfo; import com.atguigu.tingshu.query.album.AlbumInfoQuery; import com.atguigu.tingshu.vo.album.AlbumListVo; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @Mapper public interface AlbumInfoMapper extends BaseMapper { /** * 页条件查询当前登录用户发布专辑 * @param pageParam MP提供分页对象,自动根据页码、页大小计算SQL limit 部分 * @param albumInfoQuery 查询条件:用户ID,状态,关键字 * @return */ Page findUserAlbumPage(Page pageParam, @Param("vo") AlbumInfoQuery albumInfoQuery); } ``` **AlbumInfoMapper.xml 实现** ```xml ``` # 4、删除专辑 在本项目中所有删除都采用逻辑删除,利用MybatisPlus提供逻辑删除。在表中提供逻辑删除字段:is_deleted 在Java实体类中映射逻辑删除字段,属性使用@TableLogic,调用MP提供持久层或者业务层删除方法时候,自动实现逻辑删除(修改操作) ![删除专辑-](assets/删除专辑-.gif) > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/21 ## 4.1 控制层 **AlbumInfoApiController控制器** ```java /** * 根据专辑ID删除专辑 * * @param id * @return */ @Operation(summary = "根据专辑ID删除专辑") @DeleteMapping("/albumInfo/removeAlbumInfo/{id}") public Result removeAlbumInfo(@PathVariable Long id) { albumInfoService.removeAlbumInfo(id); return Result.ok(); } ``` ## 4.2 业务层 **AlbumInfoService接口** ```java /** * 根据专辑ID删除专辑 * @param id * @return */ void removeAlbumInfo(Long id); ``` **AlbumInfoServiceImpl实现类** ```java @Autowired private TrackInfoMapper trackInfoMapper; /** * 删除专辑(包括标签关系、统计数值) * * @param id * @return */ @Override @Transactional(rollbackFor = Exception.class) public void removeAlbumInfo(Long id) { //1.根据专辑ID查询声音表判断该专辑下是否关联声音 如果存在 不允许删除 Long count = trackInfoMapper.selectCount( new LambdaQueryWrapper() .eq(TrackInfo::getAlbumId, id) ); if (count > 0) { throw new GuiguException(500, "该专辑下存在关联声音"); } //2.删除专辑 albumInfoMapper.deleteById(id); //3.删除专辑标签关系 albumAttributeValueMapper.delete( new LambdaQueryWrapper() .eq(AlbumAttributeValue::getAlbumId, id) ); //4.删除专辑统计信息 albumStatMapper.delete( new LambdaQueryWrapper() .eq(AlbumStat::getAlbumId, id) ); //5.TODO 基于MQ删除存在Elasticsearch(全文搜索引擎)中数据 } ``` # 5、专辑修改 ![修改专辑](assets/修改专辑.gif) ## 5.1 回显数据 1. 需要根据专辑id获取到对应的回显数据,需要回显专辑与属性数据,不需要回显统计数据! 2. 根据修改内容保存最新数据 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/23 **AlbumInfoApiController控制器** ```java /** * 根据专辑ID查询专辑信息(包括专辑标签列表) * * @param id 专辑ID * @return 专辑信息 */ @Operation(summary = "根据专辑ID查询专辑信息(包括专辑标签列表)") @GetMapping("/albumInfo/getAlbumInfo/{id}") public Result getAlbumInfo(@PathVariable Long id) { AlbumInfo albumInfo = albumInfoService.getAlbumInfo(id); return Result.ok(albumInfo); } ``` **AlbumInfoService接口** ```java /** * 根据专辑ID查询专辑信息(包括专辑标签列表) * * @param id 专辑ID * @return 专辑信息 */ AlbumInfo getAlbumInfo(Long id); ``` **AlbumInfoServiceImpl实现类** ```java /** * 根据专辑ID查询专辑信息(包含专辑标签列表) * * @param id 专辑ID * @return */ @Override public AlbumInfo getAlbumInfo(Long id) { //1.根据ID查询专辑 AlbumInfo albumInfo = albumInfoMapper.selectById(id); //2.根据专辑ID查询专辑标签关系列表 if (albumInfo != null) { List albumAttributeValues = albumAttributeValueMapper .selectList( new LambdaQueryWrapper() .eq(AlbumAttributeValue::getAlbumId, id) ); albumInfo.setAlbumAttributeValueVoList(albumAttributeValues); } return albumInfo; } ``` ## 5.2 保存修改后数据 涉及的表: - album_info 根据主键进行更新 - album_attribute_value 先删除所有数据,再新增数据 > YAPI接口地址:http://192.168.200.6:3000/project/11/interface/api/25 **AlbumInfoApiController控制器** ```java /** * 修改专辑信息 * @param id 专辑ID * @param albumInfo 专辑修改后信息 * @return */ @Operation(summary = "更新专辑信息") @PutMapping("/albumInfo/updateAlbumInfo/{id}") public Result updateAlbumInfo(@PathVariable Long id, @Validated @RequestBody AlbumInfoVo albumInfoVo) { albumInfoService.updateAlbumInfo(id, albumInfoVo); return Result.ok(); } ``` **AlbumInfoService接口** ```java /** * 更新专辑信息 * @param id * @param albumInfoVo */ void updateAlbumInfo(Long id, AlbumInfoVo albumInfoVo); ``` **AlbumInfoServiceImpl实现类** ```java /** * 更新专辑信息 * * @param id * @param albumInfoVo */ @Override @Transactional(rollbackFor = Exception.class) public void updateAlbumInfo(Long id, AlbumInfoVo albumInfoVo) { //1.更新专辑信息 状态:未审核 AlbumInfo albumInfo = BeanUtil.copyProperties(albumInfoVo, AlbumInfo.class); albumInfo.setId(id); albumInfo.setStatus(ALBUM_STATUS_NO_PASS); albumInfoMapper.updateById(albumInfo); //2.更新专辑标签关系 //2.1 根据专辑ID删除原有标签关系 albumAttributeValueMapper.delete( new LambdaQueryWrapper() .eq(AlbumAttributeValue::getAlbumId, id) ); //2.2 从VO中获取提交专辑标签关系集合 再次批量保存 List albumAttributeValueVoList = albumInfoVo.getAlbumAttributeValueVoList(); if(CollUtil.isNotEmpty(albumAttributeValueVoList)){ //为专辑标签关联专辑ID,"批量"新增专辑标签关系 for (AlbumAttributeValueVo albumAttributeValueVo : albumAttributeValueVoList) { AlbumAttributeValue albumAttributeValue = BeanUtil.copyProperties(albumAttributeValueVo, AlbumAttributeValue.class); albumAttributeValue.setAlbumId(id); albumAttributeValueMapper.insert(albumAttributeValue); } } //3.TODO 再次对内容进行审核 } ```