当前位置: 首页 > news >正文

【智能协同云图库】智能协同云图库第三弹:基于腾讯云 COS 对象存储—开发图片模块

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


图片模块


一、需求分析


在设计图库系统时,优先确保用户能够查看图片功能,而上传功能暂时仅限管理员使用,以保证系统的安全性和稳定性。

基于这一原则,我们将优先实现以下功能,并按优先级排列如下:

image-20250626140756103

  1. 图片上传与创建
    • 仅管理员可用,支持选择本地图片上传,并填写相关信息,如名称、简介、标签、分类等。
    • 系统会自动解析图片的基础信息(如宽高和格式等),便于检索。
  2. 图片管理
    • 管理员可以对图库内的图片资源进行管理,包括查询和删除。
  3. 图片修改
    • 管理员可以对图片信息进行编辑,例如修改名称、简介、标签、分类等。
  4. 查看与搜索图片列表
    • 用户在主页上可按关键词搜索图片,并支持按分类、标签等筛选条件分页查看图片列表。
  5. 查看图片详情
    • 用户点击列表中的图片后,可进入详情页,查看图片的大图及相关信息,如名称、简介、分类、标签、其他图片信息(如宽高和格式等)。
  6. 图片下载
    • 用户在详情页可点击下载图片按钮,将图片保存至本地。

二、方案设计


方案设计阶段我们需要确认:

  • 库表设计
  • 如何实现图片上传和下载?
  • 创建图片的业务流程
  • 如何解析图片的信息?

库表设计


表名:picture(图片表),根据需求可以做出如下 SQL 设计:

image-20250626142700875

create table if not exists picture
(id           bigint auto_increment comment 'id' primary key,url          varchar(512)                       not null comment '图片 url',name         varchar(128)                       not null comment '图片名称',introduction varchar(512)                       null comment '简介',category     varchar(64)                        null comment '分类',tags         varchar(512)                       null comment '标签(JSON 数组)',picSize      bigint                             null comment '图片体积',picWidth     int                                null comment '图片宽度',picHeight    int                                null comment '图片高度',picScale     double                             null comment '图片宽高比例',picFormat    varchar(32)                        null comment '图片格式',userId       bigint                             not null comment '创建用户 id',createTime   datetime default current_timestamp not null comment '创建时间',editTime     datetime default current_timestamp not null comment '编辑时间',updateTime   datetime default current_timestamp not null on update current_timestamp comment '更新时间',isDelete     tinyint  default 0                 not null comment '是否删除',index idx_name (name),                 -- 提升基于图片名称的查询性能index idx_introduction (introduction), -- 用于模糊搜索图片简介index idx_category (category),         -- 提升基于分类的查询性能index idx_tags (tags),                 -- 提升基于标签的查询性能index idx_userId (userId)              -- 提升基于用户 ID 的查询性能
) comment '图片' collate = utf8mb4_unicode_ci;

注意事项:

image-20250626154659756


配置好数据源后,选择 sql 文件中的 sql 语句执行:

image-20250626155608406


如果我们要查看数据库中,各个表的详细字段,和表之间的详细信息,可以进行如下操作:

image-20250626160110714


好的,我会按照要求为内容加上相应的标题,同时严格遵守不修改原文内容的原则。以下是优化后的格式:


如何实现图片上传和下载?


图片本质上是一种 “小型” 文件,那么我们要思考:将文件上传到哪里?从哪里下载?

最简单的方式就是上传到后端项目所在的服务器,直接使用 Java 自带的文件读写 API 就能实现。

但是,这种方式存在不少缺点,比如:

image-20250626161852340

因此,除了存储一些需要清理的临时文件之外,我们通常不会将用户上传并保存的文件(比如用户头像和图片)直接上传到服务器,而是更推荐大家使用专业的第三方存储服务,专业的工具做专业的事。其中,最常用的便是 对象存储


什么是对象存储?

对象存储是一种存储 海量文件 的 分布式 存储服务,具有高扩展性、低成本、可靠安全等优点。

比如开源的对象存储服务 MinIO,还有商业版的云服务,像亚马逊 S3(Amazon S3)、阿里云对象存储(OSS)、腾讯云对象存储(COS)等等。

本节教程中,就将使用腾讯云 COS 带大家实现文件的上传和下载。

💡 对象存储等第三方云服务通常是付费功能,按照存储量、流量等方式计费。不过对于大家学习来说,由于图片存储量和访问量不大,价格非常便宜(几元 ~ 几十元),而且还有一定免费额度。


可以点击链接:推广大使特惠产品合集页优惠购买。下拉找到 全线产品,点击 存储页签,就能看到了:

image-20250626163420522


创建图片的业务流程


创建图片其实包括了 2 个过程:上传图片文件 + 补充图片信息 + 保存到数据库中

有 2 种常见的处理方式:

  1. 先上传再提交数据

    • 用户直接上传图片,系统生成图片的存储 URL
    • 然后在用户填写其他相关信息并提交后,才保存图片记录到数据库中。
  2. 上传图片时直接保存记录

    • 在用户上传图片后,系统立即生成图片的完整数据记录(包括图片 URL 和其他元信息)。
    • 无需等待用户点击提交,图片信息就立刻存入了数据库中。
    • 之后用户再填写其他图片信息,相当于编辑了已有图片记录的信息。
  3. 两种方案的优缺点:

    • 方案 1 的优点是流程简单,但缺点是如果用户不提交,图片会残留在存储中,导致空间浪费;

    • 方案 2 则可以理解为保存了 “图片稿草”,即使用户不填写任何额外信息,也能找到之前的创建记录

在我们的系统中,由于图片是核心资源,所以此处选择方案 2。便于对图片进行溯源,还可以对图片上传做一些限制 —— 比如发现用户上传资源过多,就禁止上传。


如何解析图片的信息?


根据需求,我们要获取的图片信息包括:宽度、高度、宽高比、大小、格式、名称。

主流的获取图片信息的方法主要有 2 种:

  1. 后端服务器直接处理图片,比如 Java 库 ImageIO、Python 库 Pillow,还有更成熟的专业图像处理库 OpenCV 等。
  2. 通过第三方云存储服务(如腾讯云COS、AWS S3),或图像处理 API (如ImageMagick、ExifTool)直接提取图片的元数据

由于本教程中使用腾讯云 COS 对象存储来实现文件的上传和下载,腾讯云 COS 对象存储支持在图片上传时通过 数据万象 CI_云端数据处理 _数据智能处理 - 腾讯云服务,直接获取到图片的各种基础信息

image-20250626163058267


这样一来,我们不用再单独引入一个库或者自己编写解析代码了,更方便;而且提供的免费额度足够用了,所以采用这种方式。

image-20250626164849066


三、后端开发


先准备好项目所需的依赖 —— 对象存储,然后再开发服务和接口。


创建并使用对象存储


首先进入对象存储的控制台 ,创建存储桶。

可以把存储桶理解为一个存储空间,和文件系统类似,都是根据路径找到文件或目录(比如 /test/aaa.jpg)。

可以多个项目共用一个存储桶,也可以每个项目一个。

点击创建存储桶,注意地域选择国内(离用户较近的位置)。此处访问权限先选择“公有读私有写”,因为我们的存储桶要存储允许用户公开访问图片。而如果整个存储桶要存储的文件都不允许用户访问,建议选择私有读写,更安全。

默认告警一定要勾选!因为对象存储服务的存储和访问流量都是计费的,超限后我们要第一时间得到通知并进行相应的处理。

不过也不用太担心,自己做项目的话一般是没人攻击你的,而且对象存储很便宜,正常情况下消耗的费用寥寥无几。

image-20250626165703571


然后一直点击“下一步”即可:

image-20250626165547188


image-20250626165810733


开通成功后,我们可以试着使用 web 控制台上传和浏览文件:

image-20250626165914270


上传文件后,可以使用对象存储服务为我们生成的默认域名,在线访问图片:

image-20250625184830549

当然,一般情况下我们会使用程序来操作存储桶,下面就来实现。

💡 你可能注意到了,系统提示我们 “使用默认域名” 是高风险的,因为对象存储的源站域名默认是不支持更换的,如果暴露出去被攻击者盯上了,可能你就只能迁移到一个新的存储桶了。本项目教程后续会给大家分享更安全的使用方式。


后端操作对象存储


如何在 Java 程序中使用对象存储呢?

其实非常简单,一般情况下,第三方服务都会提供比较贴心的文档教程,比如这里我们参考官方的快速入门或 Java SDK 文档,就能快速入门基本操作(增删改查都有)。

image-20250626171055846

<dependency><groupId>com.qcloud</groupId><artifactId>cos_api</artifactId><version>5.6.227</version>
</dependency>

还有更高级的学习操作方法,如果你是腾讯云熟练用户,可以直接使用API Explorer ,在线寻找操作和示例代码。

image-20250626171235330


1. 初始化客户端

参考官方文档 对象存储 快速入门_腾讯云,我们要先初始化一个 COS 客户端对象,和对象存储服务进行交互:

image-20250626171526217


对于我们的项目,只需要复用一个 COS 客户端对象即可,所以我们可以通过编写配置类初始化客户端对象


(1) 引入 COS 依赖

<!-- 腾讯云 cos 服务 -->
<dependency><groupId>com.qcloud</groupId><artifactId>cos_api</artifactId><version>5.6.227</version>
</dependency>

(2) 填写配置文件

一定要注意防止密码泄露! 所以我们新建 application-local.yml 文件

image-20250626173124772


并且在 .gitignore忽略该文件的提交,这样就不会将代码等敏感配置提交到代码仓库了

image-20250626174759149

两个 yml 文件的颜色不同,因为我们在使用 git 提交代码时, application-local.yml 会被 git 忽略,不会和其他代码一样被提交;


复制一份 application.yml,修改名字为application-local.yml ,在application-local.yml 多加一个配置,配置代码如下:

# 对象存储配置(需要从腾讯云获取)
cos:client:host: xxxsecretId: xxxsecretKey: xxxregion: xxxbucket: xxx

可以通过如下方式分别获取需要的配置:

host存储桶域名,也就是我们前面访问测试上传的图片的域名,可以在 COS 控制台的域名信息部分找到:

image-20250626175610078


secretIdsecretKey 密钥对:在腾讯云访问管理 => 密钥管理中获取。

image-20250626171840310


image-20250626172223120


region 表示地域名,可以点击获取:对象存储 地域和访问域名_腾讯云。

image-20250626183935743


bucket存储桶名,可以点进存储桶详情页获取:

image-20250626175921223


设置好application-local.yml 文件后,我们需要在项目启动前,指定启动项目的配置文件为application-local.yml ,推荐方法二:

image-20250626180655717


(3) 创建 COS 对象拦截器

在项目的 config 包下新建 CosClientConfig 类。负责读取配置文件,并创建一个 COS 客户端的 Bean。代码如下:

image-20250626183118944

@Configuration
@ConfigurationProperties(prefix = "cos.client")
@Data
public class CosClientConfig {/** 域名     */private String host;/** secretId     */private String secretId;/** 密钥(注意不要泄露)     */private String secretKey;/** 区域     */private String region;/** 桶名     */private String bucket;@Beanpublic COSClient cosClient() {// 初始化用户身份信息(secretId, secretKey)COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);// 设置bucket的区域, COS地域的简称请参照 [对象存储 地域和访问域名_腾讯云](https://www.qcloud.com/document/product/436/6224)ClientConfig clientConfig = new ClientConfig(new Region(region));// 生成cos客户端return new COSClient(cred, clientConfig);}
}

拦截器实现原理如下:

image-20250626184212486


2. 通用能力类

manager 包下新建 CosManager 类,提供通用的对象存储操作,比如文件上传、文件下载等。

image-20250626184740862

💡 Manager 也是人为约定的一种写法,表示通用的、可复用的能力,可供其他代码(比如 Service)调用。该类需要引入对象存储配置 COS 客户端,用于和 COS 进行交互

代码如下:

@Component
public class CosManager {@Resourceprivate CosClientConfig cosClientConfig;@Resourceprivate COSClient cosClient;// ... 一些操作 COS 的方法
}

3. 文件上传

操作 COS 的方法例如上传文件、下载文件…等方法,这些方法是通用的、可复用的,这些代码可以放到 manager 相关的类中 :

image-20250626185649709


对于上传对象的接口,刚开始我们的业务较简单,使用简单接口即可

image-20250626190140054

参考 官方文档 的“上传对象”部分,可以编写出文件上传的代码。


(1) 开发上传对象的服务

CosManager 新增上传对象的方法,代码如下:

image-20250626192901838

/*** 上传文件* @param key  唯一键, 最早的来源是我们的配置文件* @param file 要上传的本地文件, 导包 java.io.File* @return 将构造好的请求, 提高创建的客户端, 上传到 COS 对象中*/
public PutObjectResult putObject(String key, File file){// 调用 PutObjectRequest 构造方法, 文档中给出了对应 PutObjectRequest 的参数// 参数为 : 客户端桶的名称, 唯一键 key, 本地需要上传的文件PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);return cosClient.putObject(putObjectRequest);
}

(2) 开发上传对象的接口

为了方便测试,在 FileController 中编写测试文件上传接口。

image-20250626193145773


核心流程:

  1. 先接受用户上传的文件,指定上传的路径;

  2. 然后调用 cosManager.putObject 方法上传文件到 COS 对象存储;

  3. 上传成功后,会返回一个文件的 key(其实就是文件路径),便于我们访问和下载文件。

  4. 需要注意,测试接口一定要加上管理员权限!防止任何用户随意上传文件。

测试文件上传接口代码如下:

@Slf4j
@RestController
@RequestMapping("/file")
public class FileController {@Resourceprivate CosManager cosManager;/*** 测试文件上传* @param multipartFile 管理员用于测试上传的临时文件* @return 返回上传后文件的路径*/@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)  // 当前测试接口只有管理员才可以调用@PostMapping("/test/upload")  // 文件一般是接收前端的 form 表单, 所以一定要使用 Post 请求// 1. @RequestPart("file") 注解用于从 HTTP 请求中提取名为 "file" 的部分, 并将其绑定到方法参数 MultipartFile multipartFile 中; 它主要用于处理多部分请求(multipart/form-data), 例如文件上传public BaseResponse<String> testUploadFile(@RequestPart("file")MultipartFile multipartFile){// 2. 获取当前用户上传的文件的名称String filename = multipartFile.getOriginalFilename();// 3. 指定对象存储的路径String filepath = String.format("/test/%s",filename);// format("/test/%s",filename), 相当于拼接为 /test/filename// 4. 创建临时文件File file = null;try {// 5. 创建临时文件需要捕获异常file = File.createTempFile(filepath, null);// 第一个参数: 指定创建临时文件的本地路径; // 第二个参数: 文件后缀, 这里不指定后缀// 6. 把用户上传的文件, 传输到临时文件multipartFile.transferTo(file);// 7. 从 spring 中获取 cosManager, 调用上传文件的方法cosManager.putObject(filepath, file);// 以 filepath 为唯一键 key 参数// 8. 返回上传文件的地址return ResultUtils.success(filepath);} catch (Exception e) {// 9. 初学 COS, 先把 catch 的 IOException 修改为 Exception// 10. 打日志, 方便 COS 初学者排查错误log.error("file upload error, filepath = " + filepath, e);// 11. 抛出自定义的业务异常throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");}finally {// 12. 文件上传到 COS 后, 需要删除本地的临时文件if(file != null){boolean delete = file.delete();if(!delete){log.error("file delete error, filepath = {}", filepath);}}}}
}

(3) 测试接口

使用 local 配置启动项目:

image-20250626200700999

也可以在主配置文件中指定激活的环境配置:

spring:profiles:active: local

打开 Swagger 接口文档,登录管理员账户,测试文件上传:

image-20250626201009597


打开腾讯云 COS 对象:

image-20250626201210582


打开 test 文件,与我们刚刚上传图片,返回的路径相同:

image-20250626201234867


4. 文件下载

官方文档 介绍了 2 种文件下载方式。

  • 一种是直接下载 COS 的文件到后端服务器(适合服务器端处理文件)
    • 对象存储 下载对象_腾讯云
  • 另一种是获取到文件下载输入流(适合返回给前端用户)
    • 对象存储 快速入门_腾讯云

其实还有第三种“下载方式”:

  • 直接通过 URL 路径链接访问,适用于单一的、可以被用户公开访问的资源,比如用户头像、本项目中的公开图片。

💡 对于安全性要求较高的场景,建议

  • 先通过后端服务器进行权限校验
  • 然后从 COS 下载文件到服务器
  • 返回给前端
  • 这样可以在后端限制只有登录用户才能下载

不过还有更巧妙的方式:

  • 先通过后端服务器进行权限校验
  • 然后返回给前端一个临时秘钥
  • 之后前端可以凭借该秘钥,直接从对象存储下载
  • 不用经过服务端中转,性能更高。

对于我们目前的项目,图片本身就是公开的,直接使用第三种方式,凭借 URL 连接访问即可。但是作为一个小知识,还是给大家演示如何将对象存储的文件,下载到服务器中


(1) 开发下载对象的服务

首先在 CosManager 中新增对象下载方法,根据对象的 key 获取存储信息:

image-20250627150333051

/*** 下载对象** @param key 唯一键*/
public COSObject getObject(String key) {GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);return cosClient.getObject(getObjectRequest);
}

(2) 开发下载对象的接口

为了方便测试,在 FileController 中编写测试文件下载接口。

核心流程:

  1. 根据路径,获取到 COS 文件对象
  2. 然后将文件对象,转换为文件流
  3. 将转化的文件流,写入到 ServletResponse 对象中。
  4. 注意要设置文件下载专属的响应头
  5. 测试接口一定要加上管理员权限!防止任何用户随意上传文件。

测试文件下载接口代码如下:

image-20250627150740545

/*** 测试下载文件* @param filepath 目标文件的路径* @param response 响应对象*/
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@GetMapping("/test/download")
public void testDownloadFile(String filepath, HttpServletResponse response) throws IOException {// 1. 该方法无需返回值// 11. 将步骤 3 创建的流从 try 中拿出来, 方便关闭COSObjectInputStream cosObjectInput = null;// 下面步骤 3, 创建一个流, 使用完成后一定要关闭, 就像上传文件后, 也要删除临时文件// 8. 捕获 toByteArray() 异常, 并把后面设置响应体的操作, 同时放入 try 中try {// 10. 下载文件也可能会报错, 把下载文件的代码也放到 try 中// 2. 以文件路径为唯一键 key, 获取对应 key 的 COS 对象COSObject cosObject = cosManager.getObject(filepath);// 3. 获取文件具体内容cosObjectInput = cosObject.getObjectContent();// 7. write() 的参数是一个字节数组, 先把文件流转为具体数组byte[] bytes = IOUtils.toByteArray(cosObjectInput);// IOUtils 导的包是腾讯云的// 4. 设置响应类型, 提示浏览器下载文件response.setContentType("application/octet-stream;charset=UTF-8");// 设置的响应类型为 application/octet-stream, 表示让浏览器下载文件// 5. 在响应头中携带目标文件路径response.setHeader("Content-Disposition", "attachment; filename=" + filepath);// 6. 将文件具体内容的流 cosObjectInput 写入到响应中response.getOutputStream().write(bytes);// getOutputStream() 先获取一个缓冲区, 往输出流写内容// 9. 刷新缓冲区, 在写入内容到缓冲区后, 一定要记得刷新response.getOutputStream().flush();} catch (Exception e) {log.error("file delete error, filepath = {}", filepath);// 13. 将 IOException 修改为 Exceptionthrow new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");}finally {// 12. 释放流if(cosObjectInput != null){cosObjectInput.close();}}
}

(3) 测试接口

启动项目,复制刚刚上传到 COS 对象的文件路径:

image-20250628133701278


刷新 Swagger 接口文档,先登录管理员用户,再调用文件下载 API :

image-20250628133921338

在某些操作系统(浏览器)中,虽然图片没有显示,但通过响应码和响应大小,也能判断出图片是成功下载了

至此,后端操作对象存储的代码已编写完成,下面开发接口。


图片基础代码


首先利用 MyBatisX 插件生成图片表相关的基础代码,包括实体类、Mapper、Service。

image-20250628134752454


将生成的代码进行修改后,重构到我们自己的项目中:

image-20250628140754090


然后根据需求优化 Picture 实体类,主要有如下两点优化:

/*** 图片* @TableName picture*/
@TableName(value ="picture")
@Data
public class Picture implements Serializable {/*** id  优化 1. type = IdType.ASSIGN_ID, 设置为更长范围的 ID, 防止爬虫*/@TableId(type = IdType.ASSIGN_ID)private Long id;/*** 图片 url*/private String url;/*** 图片名称*/private String name;/*** 简介*/private String introduction;/*** 分类*/private String category;/*** 标签(JSON 数组)*/private String tags;/*** 图片体积*/private Long picSize;/*** 图片宽度*/private Integer picWidth;/*** 图片高度*/private Integer picHeight;/*** 图片宽高比例*/private Double picScale;/*** 图片格式*/private String picFormat;/*** 创建用户 id*/private Long userId;/*** 创建时间*/private Date createTime;/*** 编辑时间*/private Date editTime;/*** 更新时间*/private Date updateTime;/*** 是否删除  优化 2. 打上逻辑删除注解 @TableLogic*/@TableLogicprivate Integer isDelete;@TableField(exist = false)private static final long serialVersionUID = 1L;
}

图片上传


1. 数据模型

model.dto.picture 下新建用于接受请求参数的类。

image-20250628141409330


image-20250628150154317

因此,具体的业务流程如下:

  1. 图片上传

用户通过上传功能上传图片,系统会将图片保存到存储服务中(如本地服务器或云存储),并生成一个唯一的 图片 ID,该 ID 用于标识这张图片。

  • API 参数
    • 用户上传图片时,只需要提供图片文件本身。
  • 功能定位
    • 此步骤的目的是将图片文件存储起来,并获取一个用于后续操作的唯一 ID。
    • 此时,图片仅作为一个文件被保存,尚未与任何具体的业务实体(如用户、文章等)关联。

  1. 图片更新

如果用户在当前页面重新选择文件上传,系统应该 更新 之前上传的图片,而不是生成新的 ID。

  • 功能定位
    • 此步骤的目的是允许用户替换已上传的图片,而不是创建新的图片记录。
    • 用户可能在上传后发现图片有误,需要重新上传,但图片 ID 应保持不变。
  • API 参数
    • 用户需要提供 图片 ID新的图片文件图片 ID 用于定位需要更新的图片记录。

  1. 图片创建

图片创建是另一个独立的业务流程,它发生在图片上传之后。

用户在创建图片时,需要填写额外的信息(如图片描述、分类等),并将这些信息与之前上传的图片关联起来。

  • 功能定位
    • 此步骤的目的是将图片文件与具体的业务逻辑(如用户信息、图片用途等)绑定,形成一个完整的业务实体。
  • API 参数
    • 除了图片 ID 外,还需要提供其他表单信息(如描述、分类等)。

image-20250628145916466


根据上述说明,在图片上传请求中,需要提供两个参数:图片文件图片ID

由于图片文件参数需要通过@RequestPart("file")注解,从请求中提取文件内容,并绑定到文件参数,因此图片文件参数不能包含在实体类中。

同时,考虑到图片支持重复上传(即基础信息保持不变,仅更新图片文件),实体类中只需包含图片 ID 参数即可

@Data
public class PictureUploadRequest implements Serializable {/*** 图片 id(用于修改)*/private Long id;private static final long serialVersionUID = 1L;
}

model.vo 下新建上传成功后返回给前端的响应类,这是一个视图包装类,可以额外关联上传图片的用户信息。

image-20250628150914539

@Data
public class PictureVO implements Serializable {/*** id*/private Long id;/*** 图片 url*/private String url;/*** 图片名称*/private String name;/*** 简介*/private String introduction;/*** 标签*/private List<String> tags;/*** 分类*/private String category;/*** 文件体积*/private Long picSize;/*** 图片宽度*/private Integer picWidth;/*** 图片高度*/private Integer picHeight;/*** 图片比例*/private Double picScale;/*** 图片格式*/private String picFormat;/*** 用户 id*/private Long userId;/*** 创建时间*/private Date createTime;/*** 编辑时间*/private Date editTime;/*** 更新时间*/private Date updateTime;/*** 创建用户信息, 这个字段在 picture 实体类中没有, 用于标识图片的作者信息*/private UserVO user;private static final long serialVersionUID = 1L;/*** 封装类转对象*/public static Picture voToObj(PictureVO pictureVO) {if (pictureVO == null) {return null;}Picture picture = new Picture();BeanUtils.copyProperties(pictureVO, picture);// 类型不同,需要转换picture.setTags(JSONUtil.toJsonStr(pictureVO.getTags()));return picture;}/*** 对象转封装类*/public static PictureVO objToVo(Picture picture) {if (picture == null) {return null;}PictureVO pictureVO = new PictureVO();BeanUtils.copyProperties(picture, pictureVO);// 类型不同,需要转换pictureVO.setTags(JSONUtil.toList(picture.getTags(), String.class));return pictureVO;}
}

还可以编写 Picture 实体类和该 VO 类的转换方法,便于后续快速传值:

image-20250628151629109


注意 tags 类型 StringList<String>互相转换的方法:

image-20250628152909790


2. 通用文件上传服务

虽然我们已经编写了通用的对象存储操作类 CosManager

image-20250628153410244

@Component
public class CosManager {@Resourceprivate CosClientConfig cosClientConfig;@Resourceprivate COSClient cosClient;/*** 上传文件* @param key  唯一键* @param file 本地文件, 导包 java.io.File* @return 将构造好的请求, 提高创建的客户端, 上传到 COS 对象中*/public PutObjectResult putObject(String key, File file){// 调用请求的构造方法, 参数为 : 客户端桶的名称, 唯一键 key, 本地需要上传的文件PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);return cosClient.putObject(putObjectRequest);}/*** 下载对象* @param key 唯一键* @return*/public COSObject getObject(String key){GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);return cosClient.getObject(getObjectRequest);}
}

但这个类并不能直接满足我们的图片上传需求。例如:

  • 图片是否符合要求?需要校验
  • 将图片上传到哪里?需要指定路径
  • 如何解析图片?需要使用数据万象服务

所以,可以针对我们的项目,编写一个更贴合业务的文件上传服务 FileManager(这里用 Service 也可以),该服务提供一个上传图片,并返回图片解析信息的方法。

image-20250628153251576

@Service  // 将 @Component 注解修改为 @Service, 因为这个类是偏业务的
@Slf4j    // 打上日志注解
public class FileManager {@Resourceprivate CosClientConfig cosClientConfig;// 去掉 cosClient, 加上 cosManager@Resourceprivate CosManager cosManager;// ...
}

(1)新增用于接受图片解析信息的包装类

model.dto包中创建一个子包file,用于存放与接收并解析图片信息相关的请求包装类

这样做的目的是避免与picture相关的内容混用,便于实现代码的隔离与复用

此外,鉴于该项目后续计划采用领域驱动设计(DDD)进行封装,因此在前期可以提前开展类似这样的模块化准备工作,为后续的架构设计奠定基础。

image-20250628153643092


model.dto.file 中,新增用于接受图片解析信息的包装类

@Data
public class UploadPictureResult {/*** 图片地址*/private String url;/*** 图片名称*/private String picName;/*** 文件体积*/private Long picSize;/*** 图片宽度*/private int picWidth;/*** 图片高度*/private int picHeight;/*** 图片宽高比*/private Double picScale;/*** 图片格式*/private String picFormat;
}

该类的字段,与数据库实体类的字段是一 一对应的,所以也可以直接复制一份数据库实体类,后续增加或删减一些字段即可;


(2) 添加上传图片并解析图片的方法

添加上传图片并解析图片的方法,参考 数据万象 的文档,在 CosManager 中添加上传图片并解析图片的方法:

注意:

  • CosManager 与业务逻辑无关,它主要用于存放通用代码,例如文件的上传和下载功能。
  • FileManager则与业务相关,它基于CosManager中的通用代码,进一步进行修改和封装,以满足业务需求。

image-20250628154434005


public PutObjectResult putObject(String key, File file){PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);return cosClient.putObject(putObjectRequest);
}

复制一份上传图片的代码,并添加对图片的信息进行解析和校验的逻辑,这些逻辑需要我们参考官方文档:对象存储 图片持久化处理_腾讯云

image-20250628155638346


方法名为putPictureObject(),表示通用的上传图片文件的方法,putObject()表示通用的上传文件的方法,没有指定文件类型:

/*** 复制一份上传图片的代码, 并添加对图片的信息进行解析和校验的逻辑* @param key* @param file* @return*/
public PutObjectResult putPictureObject(String key, File file){// 上传图片请求PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file);// 获取图片信息:// 1. 定义一个图片处理规则 (获取图片的基本信息, 也被视作为一种对图片的处理)PicOperations picOperations = new PicOperations();// 2. 设置是否需要返回原图信息, 1 表示返回picOperations.setIsPicInfo(1);// 3. 构造处理参数: 将图片处理规则设置到上传图片请求中putObjectRequest.setPicOperations(picOperations);return cosClient.putObject(putObjectRequest);
}

如果你之前没有使用过数据万象,需要先 开通数据万象并授权,否则会报错。

image-20250628162441682


(3) 编写上传图片的方法

FileManager 中编写上传图片的方法:

@Slf4j
@Service
public class FileManager {@Resourceprivate CosClientConfig cosClientConfig;@Resourceprivate CosManager cosManager;/**** @param multipartFile 上传的文件* @param uploadPathPrefix 上传文件的路径前缀* 由于这个方法是通用的上传图片文件的方法, 因此我们使用上传路径前缀, 而不是具体路径* 具体的路径, 可解析上传文件的具体信息* @return 上传图片后解析出的结果*/public UploadPictureResult uploadPicture(MultipartFile multipartFile, String uploadPathPrefix){// 1. 校验图片(校验逻辑比较复杂, 单独写一个方法)validPicture(multipartFile);// 2. 图片上传地址String uuid = RandomUtil.randomString(16);// 文件可以重名, 使用 UUID 标识不同的文件, RandomUtil 是 hutool 工具类, 生成 16 位唯一且随机字符串// 3. 获取文件原始名称String originalFilename = multipartFile.getOriginalFilename();// 4. 确定最终上传文件的文件名: 创建文件时间戳-uuid-原始文件名后缀String uploadFilename = String.format("%s_%s_%s",DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originalFilename));// format() 第一个参数是拼接的形式, %s_%s_%s 每一个 %s 都是一个需要拼接的字符串// 最终文件上传名称由我们自己拼接, 不能用原始文件名, 可以提高安全性, 否则可能会导致 URL 冲突// 5.确定最终上传文件的文件路径: /uploadPathPrefix/uploadFilenameString uploadPath = String.format("/%s/%s", uploadPathPrefix,uploadFilename);// 最终路径: 文件前缀参数(可以由用户自己指定, 比如短视频放入不同收藏夹) + 拼接好的最终文件名称// 6. 将文件上传到对象存储中(FileController 有现成代码)File file = null;try {// 11. 将原来的 filepath 修改为 uploadPathfile = File.createTempFile(uploadPath, null);multipartFile.transferTo(file);// 12. 获取上传结果对象 PutObjectResult putObjectResult = cosManager.putObject(uploadPath, file);// 之前不需要获取, 是因为我们不需要解析文件信息, 现在获取结果对象, 方便后续对文件进行解析// 13. 从文件的结果对象中, 获取文件的原始信息, 再从原始信息中获取图片对象 ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();// 14. 封装返回结果UploadPictureResult uploadPictureResult = new UploadPictureResult();// uploadPictureResult.allsetuploadPictureResult.setUrl( cosClientConfig.getHost() + "/" + uploadPath);  //   域名/上传路径 = 绝对路径uploadPictureResult.setPicName(originalFilename);uploadPictureResult.setPicSize(FileUtil.size(file));// 15. 计算图片的宽、高、宽高比  imageInfo.allgetint picWidth = imageInfo.getWidth();int picHeight = imageInfo.getHeight();// 16. 计算宽高比double picScale = NumberUtil.round(picWidth * 1.0/picHeight, 2).doubleValue();// NumberUtil.round() 的两个参数是小数, 精度// int/int 可能会造成精度丢失, 将 picWidth/picHeight 改为 picWidth * 1.0/picHeight, uploadPictureResult.setPicWidth(picWidth);uploadPictureResult.setPicHeight(picHeight);uploadPictureResult.setPicScale(picScale);uploadPictureResult.setPicFormat(imageInfo.getFormat());  // 从图片对象中获取格式// 17. 设置返回结果return uploadPictureResult;} catch (Exception e) {// 18. 修改异常错误日志log.error("图片上传到对象存储失败 " , e);throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");}finally {// 7. finally 删除临时文件的逻辑可以抽出来(选中代码 + ctrl+alt+m)deleteTempFile(file);// 8. 修改封装的方法名, 该方法 private 改为 public}}/*** 校验文件* @param multipartFile*/private void validPicture(MultipartFile multipartFile) {// 1. 文件为空, 抛异常ThrowUtils.throwIf(multipartFile == null, ErrorCode.PARAMS_ERROR, "文件不能为空");// 2. 文件不为空, 校验文件大小long fileSize = multipartFile.getSize();// getSize() 可以获取文件大小, 以字节为单位final long ONE_M = 1024*1024;// 定义单位 MBThrowUtils.throwIf(fileSize > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");// 3. 校验文件后缀String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());// FileUtil 是 hutool 工具类, FileUtil.getSuffix() 可以获取文件后缀// 4. 定义允许上传的文件的后缀列表final List<String> ALLOW_FORMAT_LIST = Arrays.asList("jpg", "png", "jpeg", "webp");// 5. 校验当前文件是否包含后缀列表ThrowUtils.throwIf(!ALLOW_FORMAT_LIST.contains(fileSuffix), ErrorCode.PARAMS_ERROR, "文件类型错误");}/*** 删除临时文件* @param file*/public void deleteTempFile(File file) {if(file != null){// 9. 修改变量名 delete 为 deleteResultboolean deleteResult = file.delete();if(!deleteResult){// 10. 日志第二个参数 filepath 改为 file.getAbsoluteFile()log.error("file delete error, filepath = {}", file.getAbsoluteFile());}}}
}

实现关键点

  • 由于文件校验规则较复杂,单独抽象为 validPicture 方法,对文件大小、类型进行校验。
  • 文件上传时,会先在本地创建临时文件,无论上传是否成功,都要记得删除临时文件,否则会导致资源泄露。
  • 可以根据自己的需求定义文件上传地址,比如此处给文件名前增加了上传日期和 16 位 uuid 随机数,便于了解文件上传时间并防止文件重复。还预留了一个 uploadPathPrefix 参数,由调用方指定上传文件到哪个目录。
  • 如果多个项目共享存储桶,可以给上传文件路径再加一个 ProjectName 前缀。不过建议还是每个项目独立分配资源

3. 服务开发

PictureService 中编写上传图片的方法:

image-20250628183710132

/*** 上传图片** @param multipartFile  前端传的需要上传的文件* @param pictureUploadRequest  请求体* @param loginUser  当前登录用户(主要用于判断当前用户是否有权限调用该 api) * @return*/
PictureVO uploadPicture(MultipartFile multipartFile,PictureUploadRequest pictureUploadRequest,User loginUser);

编写接口的实现类:

image-20250628183735448

@Service
public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>implements PictureService {// 6. 引入 FileManager 对象@Resourceprivate FileManager fileManager;@Overridepublic PictureVO uploadPicture(MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, User loginUser) {// 1. 校验参数, 用户未登录, 抛出没有权限的异常ThrowUtils.throwIf(loginUser == null , ErrorCode.NO_AUTH_ERROR);// 2. 判断是新增图片, 还是更新图片, 所以先判断图片是否存在Long pictureId = null;if(pictureUploadRequest != null){// 3. 如果传入的请求不为空, 才获取请求中的图片 IDpictureId = pictureUploadRequest.getId();}// 4. 图片 ID 不为空, 查数据库中是否有对应的图片 IDif(pictureId != null){boolean exists = this.lambdaQuery().eq(Picture::getId, pictureId).exists();// 5. 如果数据库中没有图片, 则抛异常, 因为这是更新图片的接口ThrowUtils.throwIf(!exists, ErrorCode.NOT_FOUND_ERROR, "图片不存在");}// 7. 定义上传文件的前缀 public/登录用户 IDString uploadPathPrefix = String.format("public/%s", loginUser.getId());// 根据用户划分前缀, 当前的图片文件上传到公共图库, 因此前缀定义为 public// 8. 上传图片, 上传图片 API 需要的参数(原始文件 + 文件前缀), 获取上传文件结果对象,UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix);// 9. 构造要入库的图片信息(样板代码)Picture picture = new Picture();picture.setUrl(uploadPictureResult.getUrl());picture.setName(uploadPictureResult.getPicName());picture.setPicSize(uploadPictureResult.getPicSize());picture.setPicWidth(uploadPictureResult.getPicWidth());picture.setPicHeight(uploadPictureResult.getPicHeight());picture.setPicScale(uploadPictureResult.getPicScale());picture.setPicFormat(uploadPictureResult.getPicFormat());// 从当前登录用户中获取 userIdpicture.setUserId(loginUser.getId());// 10. 操作数据库, 如果 pictureId 不为空, 表示更新图片, 否则为新增图片if(pictureId!=null){// 11. 如果是更新, 需要补充 id 和编辑时间picture.setId(pictureId);picture.setEditTime(new Date());}// 12. 利用 MyBatis 框架的 API,根据实体对象 picture 是否存在 ID 值, 来决定是执行插入操作还是更新操作boolean result = this.saveOrUpdate(picture);// 13. result 返回 false, 表示数据库不存在该图片, 不能调用图片上传(更新)接口ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败, 数据库操作失败");// 14. 对数据进行脱敏, 并返回return PictureVO.objToVo(picture);}
}

注意:

  • 我们将所有图片都放到了 public 目录下,并且每个用户的图片存储到对应用户 id 的目录下,便于管理。
  • 如果 pictureId 不为空,表示更新已有图片的信息,需要判断对应 id 的图片是否存在,并且更新时要指定 editTime 编辑时间。
  • 可以调用 MyBatis Plus 提供的 saveOrUpdate 方法兼容创建和更新操作。

4. 接口开发

PictureController 中编写上传图片接口,注意仅管理员可用:

image-20250628184949228

@RequestMapping("/picture")
@RestController
public class PictureController {@Resourceprivate UserService userService;@Resourceprivate PictureService pictureService;/*** 上传图片(可重新上传)*/@PostMapping("/upload")@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)public BaseResponse<PictureVO> uploadPicture(@RequestPart("file") MultipartFile multipartFile,PictureUploadRequest pictureUploadRequest, HttpServletRequest request) {// 1. 获取用户信息, 用于后续判断用户是否登录User loginUser = userService.getLoginUser(request);// 2. 处理传入的文件(上传文件, 对返回结果脱敏)PictureVO pictureVO = pictureService.uploadPicture(multipartFile, pictureUploadRequest, loginUser);// 3. 封装脱敏结果, 统一返回值return ResultUtils.success(pictureVO);}
}

5. 测试接口

使用 Swagger 进行测试:

image-20250628190254817


发现当上传图片过大时,会触发一段报错:

image-20250628190358200

但这个报错不是我们自定义的异常导致的,而是由于 Tomcat 服务器默认限制了请求中,文件上传的大小


需要在 application.yml 中更改配置,调大允许上传的文件大小:

spring:# 开放更大的文件上传体积servlet:multipart:max-file-size: 10MB

再次测试:

image-20250628190702408


image-20250628190808300


后端错误日志:

image-20250628190928944


image-20250628191740410


再次测试:

image-20250628195126247


6. 扩展思路

  1. 可以用枚举类(FileUploadBizEnum)支持根据业务场景区分文件上传路径、校验规则等,从而复用 FileManager
  2. 目前在文件上传时,会先在本地创建临时文件。如果你不需要对文件进行额外的处理、想进一步提高性能,可以直接用流的方式将请求中的文件上传到 COS。以下代码仅供参考:
// 上传文件
static String uploadToCOS(MultipartFile multipartFile, String bucketName, String key) throws Exception {// 创建 COS 客户端COSClient cosClient = createCOSClient();try (InputStream inputStream = multipartFile.getInputStream()) {// 元信息配置ObjectMetadata metadata = new ObjectMetadata();metadata.setContentLength(multipartFile.getSize());metadata.setContentType(multipartFile.getContentType());// 创建上传请求PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, inputStream, metadata);// 上传文件cosClient.putObject(putObjectRequest);// 生成访问链接return "https://" + bucketName + ".cos." + cosClient.getClientConfig().getRegion().getRegionName()+ ".myqcloud.com/" + key;} finally {cosClient.shutdown();}
}
  1. 补充更严格的校验,比如为支持的图片格式定义枚举,仅允许上传枚举定义的格式。

图片管理


图片管理功能具体可以拆分为:

  • 【管理员】根据 id 删除图片
  • 【管理员】更新图片
  • 【管理员】分页获取图片列表(不需要脱敏和限制条数)
  • 【管理员】根据 id 获取图片(不需要脱敏)
  • 分页获取图片列表(需要脱敏和限制条数)
  • 根据 id 获取图片(需要脱敏)
  • 修改图片

1. 数据模型

每个操作都需要提供一个请求类,都放在 model.dto.picture 包下。

image-20250629140544062


(1) 图片更新请求,给管理员使用,注意要将 tags 的类型改为 List<String>,便于前端上传:

@Data
public class PictureUpdateRequest implements Serializable {/*** id*/private Long id;/*** 图片名称*/private String name;/*** 简介*/private String introduction;/*** 分类*/private String category;/*** 标签 使用 List, 而不是 String, 方便前端传 JS 数组*/private List<String> tags;private static final long serialVersionUID = 1L;
}

(2) 图片修改请求,一般情况下给普通用户使用,可修改的字段范围小于更新请求:

@Data
public class PictureEditRequest implements Serializable {/*** id*/private Long id;/*** 片名称*/private String name;/*** 简介*/private String introduction;/*** 分类*/private String category;/*** 标签*/private List<String> tags;private static final long serialVersionUID = 1L;
}

(3) 图片查询请求,需要继承公共包中的 PageRequest 来支持分页查询:

@EqualsAndHashCode(callSuper = true)
// Lombok 提供的一个注解,用于自动生成 equals() 和 hashCode() 方法@Data
public class PictureQueryRequest extends PageRequest implements Serializable {/*** id*/private Long id;/*** 图片名称*/private String name;/*** 简介*/private String introduction;/*** 分类*/private String category;/*** 标签*/private List<String> tags;/*** 文件体积*/private Long picSize;/*** 图片宽度*/private Integer picWidth;/*** 图片高度*/private Integer picHeight;/*** 图片比例*/private Double picScale;/*** 图片格式*/private String picFormat;/*** 搜索词(同时搜名称、简介等)*/private String searchText;/*** 用户 id*/private Long userId;private static final long serialVersionUID = 1L;
}

2. 服务开发

(1) 开发判断用户是否为管理员的服务

UserService 中编写判断用户是否为管理员的方法,后续开发中会用到。

接口代码:

image-20250629141057941

/*** 是否为管理员* @param user* @return*/
boolean isAdmin(User user);

实现类:


image-20250629141129106

@Override
public boolean isAdmin(User user) {return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole());
}

image-20250629141925116


(2) 开发分页查询图片的服务

对于分页查询接口,需要根据用户传入的参数构造 SQL 查询

由于使用Mybatis Plus框架,不用自己拼接 SQL 了,而是通过构造 QueryWrapper 对象来生成 SQL 查询。

可以在 PictureService 中编写一个方法,专门用于将查询请求转为 QueryWrapper 对象:

@Override
public QueryWrapper<Picture> getQueryWrapper(PictureQueryRequest pictureQueryRequest) {QueryWrapper<Picture> queryWrapper = new QueryWrapper<>();if (pictureQueryRequest == null) {return queryWrapper;}// 从对象中取值 pictureQueryRequest.allget()Long id = pictureQueryRequest.getId();String name = pictureQueryRequest.getName();String introduction = pictureQueryRequest.getIntroduction();String category = pictureQueryRequest.getCategory();List<String> tags = pictureQueryRequest.getTags();Long picSize = pictureQueryRequest.getPicSize();Integer picWidth = pictureQueryRequest.getPicWidth();Integer picHeight = pictureQueryRequest.getPicHeight();Double picScale = pictureQueryRequest.getPicScale();String picFormat = pictureQueryRequest.getPicFormat();String searchText = pictureQueryRequest.getSearchText();Long userId = pictureQueryRequest.getUserId();String sortField = pictureQueryRequest.getSortField();String sortOrder = pictureQueryRequest.getSortOrder();// 从多字段中搜索if (StrUtil.isNotBlank(searchText)) {// 需要拼接查询条件// sql : and (name like "%xxx%" or introduction like "%xxx%")queryWrapper.and(qw -> qw.like("name", searchText).or().like("introduction", searchText));}queryWrapper.eq(ObjUtil.isNotEmpty(id), "id", id);queryWrapper.eq(ObjUtil.isNotEmpty(userId), "userId", userId);queryWrapper.like(StrUtil.isNotBlank(name), "name", name);queryWrapper.like(StrUtil.isNotBlank(introduction), "introduction", introduction);queryWrapper.like(StrUtil.isNotBlank(picFormat), "picFormat", picFormat);queryWrapper.eq(StrUtil.isNotBlank(category), "category", category);queryWrapper.eq(ObjUtil.isNotEmpty(picWidth), "picWidth", picWidth);queryWrapper.eq(ObjUtil.isNotEmpty(picHeight), "picHeight", picHeight);queryWrapper.eq(ObjUtil.isNotEmpty(picSize), "picSize", picSize);queryWrapper.eq(ObjUtil.isNotEmpty(picScale), "picScale", picScale);// JSON 数组查询if (CollUtil.isNotEmpty(tags)) {// and (tag like "%\"Java\"%" and like "%\"Python\"%")for (String tag : tags) {queryWrapper.like("tags", "\"" + tag + "\"");}}// 排序queryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField);return queryWrapper;
}

上面的代码中,注意两点:

  1. searchText 支持同时从 nameintroduction 中检索,可以用queryWrapper or 语法构造查询条件
  2. 由于 tags 在数据库中存储的是 JSON 格式的字符串,如果前端要传多个tag(必须同时存在才查出),需要遍历 tags 数组,每个标签都使用 like 模糊查询,将这些条件组合在一起。

MySQL 5.7 开始,MySQL提供了 JSON_CONTAINS 函数,可以用来检查一个 JSON数组 中是否包含某个元素:

SELECT * FROM picture  
WHERE JSON_CONTAINS(tags, 'yupi');

需要在程序中编写 MyBatis 的自定义 SQL 实现。


(3) 开发获取图片封装的服务

编写获取图片封装的方法,可以为原有的图片关联创建用户的信息。

image-20250629144154448


获取单个图片封装:

@Override  
public PictureVO getPictureVO(Picture picture, HttpServletRequest request) {  // 对象转封装类  PictureVO pictureVO = PictureVO.objToVo(picture);  // 关联查询用户信息  Long userId = picture.getUserId();  if (userId != null && userId > 0) {  User user = userService.getById(userId);  UserVO userVO = userService.getUserVO(user);  pictureVO.setUser(userVO);  }  return pictureVO;  
}

分页获取图片封装

/*** 分页查询* @param picturePage* @param request* @return 将分页 Page 中的 picture 转为分页 Page 中的 pictureVO*/
@Override
public Page<PictureVO> getPictureVOPage(Page<Picture> picturePage, HttpServletRequest request) {// 1. 取出分页对象中的值 picturePage.getRecords()List<Picture> pictureList = picturePage.getRecords();// 2. 创建 Page<PictureVO>, 调用 Page(当前页, 每页尺寸, 总数据量) 的构造方法Page<PictureVO> pictureVOPage = new Page<>(picturePage.getCurrent(), picturePage.getSize(), picturePage.getTotal());// 3. 判断存放分页对象值的列表是否为空if (CollUtil.isEmpty(pictureList)) {return pictureVOPage;}// 4. 对象列表 => 封装对象列表List<PictureVO> pictureVOList = pictureList.stream().map(PictureVO::objToVo).collect(Collectors.toList());// pictureList.stream():将 pictureList 转换为流。//.map(PictureVO::objToVo):使用 PictureVO.objToVo() 方法, 将流中的每个 Picture 对象转换为 PictureVO 对象。//.collect(Collectors.toList()):将转换后的 PictureVO 对象收集到一个新的 List 中。// 5. 关联查询用户信息Set<Long> userIdSet = pictureList.stream().map(Picture::getUserId).collect(Collectors.toSet());// .map(Picture::getUserId) 取出封装图片列表中, 所有用户的 Id, 并将这些 id 收集为一个新的 Set 集合// 6. 将一个用户列表, 按照用户 ID 分组, Map<userId, 具有相同 userId 的用户列表>Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream().collect(Collectors.groupingBy(User::getId));// userService.listByIds(userIdSet): 根据 userIdSet 查询出对应的用户列表,  返回值是一个List<User>,包含所有匹配的User 对象// Collectors.groupingBy() : 收集器, 对流中的 User 对象进行分组// User::getId : 一个方法引用, 表示以 User 对象的 id 属性作为分组依据。// 7. 填充图片封装对象 pictureVO 中, 关于作者信息的属性 user// 遍历封装的图片列表pictureVOList.forEach(pictureVO -> {// 获取当前图片的用户IDLong userId = pictureVO.getUserId();// 初始化用户对象为 nullUser user = null;// 检查 Map<userId, List<User>> 中是否存在该 userId 对应的用户列表if (userIdUserListMap.containsKey(userId)) {// 如果存在,获取该 userId 对应的用户列表,并取第一个用户对象user = userIdUserListMap.get(userId).get(0);}// 将用户对象转换为 UserVO,并设置到当前 pictureVO 的 user 属性中pictureVO.setUser(userService.getUserVO(user));});// 8. 将处理好的图片封装列表, 重新赋值给分页对象的具体值pictureVOPage.setRecords(pictureVOList);return pictureVOPage;
}

注意,这里我们做了个小优化,不是针对每条数据都查询一次用户,而是先获取到要查询的用户 id 列表,只发送一次查询用户表的请求,再将查到的值设置到图片对象中。


(4) 开发图片数据校验的服务

编写图片数据校验方法,用于更新修改图片时进行判断:

@Override  
public void validPicture(Picture picture) {  ThrowUtils.throwIf(picture == null, ErrorCode.PARAMS_ERROR);  // 从对象中取值  Long id = picture.getId();  String url = picture.getUrl();  String introduction = picture.getIntroduction();  // 修改数据时,id 不能为空,有参数则校验  ThrowUtils.throwIf(ObjUtil.isNull(id), ErrorCode.PARAMS_ERROR, "id 不能为空");  if (StrUtil.isNotBlank(url)) {  ThrowUtils.throwIf(url.length() > 1024, ErrorCode.PARAMS_ERROR, "url 过长");  }  if (StrUtil.isNotBlank(introduction)) {  ThrowUtils.throwIf(introduction.length() > 800, ErrorCode.PARAMS_ERROR, "简介过长");  }  
}

可以根据自己的需要,补充更多校验规则。


3. 接口开发

上述功能其实都是样板代码,俗称 “增删改查”。代码实现比较简单,注意添加对应的权限注解、做好参数校验即可:

image-20250629152811448


删除图片

@PostMapping("/delete")
public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request){// 1. DeleteRequest 类定义在 common 中, 而不在 service 中, 因为删除逻辑对于删除用户、删除图片, 都是类似的if(deleteRequest == null || deleteRequest.getId() <= 0){throw new BusinessException(ErrorCode.PARAMS_ERROR);}// 2. 根据 HttpServletRequest 参数, 获取登录用户信息User loginUser = userService.getLoginUser(request);// 3. 判断图片是否存在Long id = deleteRequest.getId();// 4. 调用数据库 getById(), 如果图片存在, 定义为 oldPicture 对象Picture oldPicture = pictureService.getById(id);// 5. 图片不存在ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);// 6. 删除图片权限: 管理员、图片作者if(!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)){throw new BusinessException(ErrorCode.NO_AUTH_ERROR);}// 7. 操作数据库删除图片boolean result = pictureService.removeById(id);ThrowUtils.throwIf(result == false, ErrorCode.OPERATION_ERROR);// 8. 只要接口没抛异常, 就一定删除成功了return ResultUtils.success(true);
}

更新图片(仅管理员可用)

/*** 更新图片(仅管理员可用)*/
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updatePicture(@RequestBody PictureUpdateRequest pictureUpdateRequest) {if (pictureUpdateRequest == null || pictureUpdateRequest.getId() <= 0) {throw new BusinessException(ErrorCode.PARAMS_ERROR);}// 将实体类和 DTO 进行转换Picture picture = new Picture();BeanUtils.copyProperties(pictureUpdateRequest, picture);// 注意将 list 转为 stringpicture.setTags(JSONUtil.toJsonStr(pictureUpdateRequest.getTags()));// 数据校验pictureService.validPicture(picture);// 判断是否存在long id = pictureUpdateRequest.getId();Picture oldPicture = pictureService.getById(id);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);// 操作数据库boolean result = pictureService.updateById(picture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);return ResultUtils.success(true);
}

根据 id 获取图片(仅管理员可用)

这个接口仅管理员可用,不需要对获取的图片信息进行脱敏

/*** 根据 id 获取图片(仅管理员可用)*/
@GetMapping("/get")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Picture> getPictureById(long id, HttpServletRequest request) {ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);// 查询数据库Picture picture = pictureService.getById(id);ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);// 获取封装类return ResultUtils.success(picture);
}

根据 id 获取图片(封装类)

/*** 根据 id 获取图片(封装类)*/
@GetMapping("/get/vo")
public BaseResponse<PictureVO> getPictureVOById(long id, HttpServletRequest request) {ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);// 查询数据库Picture picture = pictureService.getById(id);ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);// 获取封装类return ResultUtils.success(pictureService.getPictureVO(picture, request));
}

分页获取图片列表(仅管理员可用)

/*** 分页获取图片列表(仅管理员可用)*/
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<Picture>> listPictureByPage(@RequestBody PictureQueryRequest pictureQueryRequest) {long current = pictureQueryRequest.getCurrent();long size = pictureQueryRequest.getPageSize();// 查询数据库Page<Picture> picturePage = pictureService.page(new Page<>(current, size),pictureService.getQueryWrapper(pictureQueryRequest));return ResultUtils.success(picturePage);
}

分页获取图片列表(封装类)

/*** 分页获取图片列表(封装类)*/
@PostMapping("/list/page/vo")
public BaseResponse<Page<PictureVO>> listPictureVOByPage(@RequestBody PictureQueryRequest pictureQueryRequest, HttpServletRequest request) {long current = pictureQueryRequest.getCurrent();long size = pictureQueryRequest.getPageSize();// 限制爬虫ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);// 查询数据库Page<Picture> picturePage = pictureService.page(new Page<>(current, size),pictureService.getQueryWrapper(pictureQueryRequest));// 获取封装类return ResultUtils.success(pictureService.getPictureVOPage(picturePage, request));
}

编辑图片(给用户使用)

/*** 编辑图片(给用户使用)*/
@PostMapping("/edit")
public BaseResponse<Boolean> editPicture(@RequestBody PictureEditRequest pictureEditRequest, HttpServletRequest request) {if (pictureEditRequest == null || pictureEditRequest.getId() <= 0) {throw new BusinessException(ErrorCode.PARAMS_ERROR);}// 在此处将实体类和 DTO 进行转换Picture picture = new Picture();BeanUtils.copyProperties(pictureEditRequest, picture);// 注意将 list 转为 stringpicture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));// 设置编辑时间, 不会因为修改数据库, 就自动更新编辑时间, 需要我们手动设置picture.setEditTime(new Date());// 数据校验pictureService.validPicture(picture);User loginUser = userService.getLoginUser(request);// 判断是否存在long id = pictureEditRequest.getId();Picture oldPicture = pictureService.getById(id);ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);// 仅本人或管理员可编辑if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {throw new BusinessException(ErrorCode.NO_AUTH_ERROR);}// 操作数据库boolean result = pictureService.updateById(picture);ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);return ResultUtils.success(true);
}

注意:在修改和编辑接口中,需要将请求包装对象转换为数据库实体类,便于操作数据库。

转换过程中,由于 tags 类型不同(List<String>String),需要手动转换:

// 在此处将实体类和 DTO 进行转换
Picture picture = new Picture();
BeanUtils.copyProperties(pictureEditRequest, picture);
// 注意将 list 转为 string
picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));

💡 如果觉得手动转换比较麻烦,也可以用一些工具提供的注解,让类库自动帮你转换。

比如 JSON 类型的字段 tags 可以使用 MyBatis Plus 的标注:

  • @TableField(typeHandler = JacksonTypeHandler.class)
  • 字段类型处理器参考文档 | MyBatis-Plus。

获取预置标签和分类


根据需求,要支持用户根据标签和分类搜索图片,我们可以给用户列举一些常用的标签和分类,便于筛选。

在项目前期规模不大的时候,我们没必要将标签和分类单独用数据表来维护了,直接在 PictureController 中写一个接口,返回预设的固定数据即可:

image-20250629160728440

@GetMapping("/tag_category")
public BaseResponse<PictureTagCategory> listPictureTagCategory() {PictureTagCategory pictureTagCategory = new PictureTagCategory();List<String> tagList = Arrays.asList("热门", "搞笑", "生活", "高清", "艺术", "校园", "背景", "简历", "创意");List<String> categoryList = Arrays.asList("模板", "电商", "表情包", "素材", "海报");pictureTagCategory.setTagList(tagList);pictureTagCategory.setCategoryList(categoryList);return ResultUtils.success(pictureTagCategory);
}

image-20250629160715007

/*** 标签列表、分类列表的视图, 用于返回给前端, 放到 model.vo 中*/
@Data
public class PictureTagCategory {/*** 标签列表*/private List<String> tagList;/*** 分类列表*/private List<String> categoryList;
}

随着系统规模和数据不断扩大,可以再改为使用配置中心或数据库动态管理这些数据,或者通过定时任务计算出热门的图片分类和标签。

至此,图片相关的后端接口开发完毕,大家可以按需完善上述代码。


在这里插入图片描述

在这里插入图片描述

http://www.lryc.cn/news/576724.html

相关文章:

  • Leetcode 3598. Longest Common Prefix Between Adjacent Strings After Removals
  • [database] Closure computation | e-r diagram | SQL
  • 【LeetCode 热题 100】560. 和为 K 的子数组——(解法二)前缀和+哈希表
  • swift-22-面向协议编程、响应式编程
  • SpringSecurity6-oauth2-三方gitee授权-授权码模式
  • 加密货币:USDC和比特币有什么区别?
  • web3区块链-ETH以太坊
  • 代理模式 - Flutter中的智能替身,掌控对象访问的每一道关卡!
  • aws(学习笔记第四十八课) appsync-graphql-dynamodb
  • Docker错误问题解决方法
  • Keil MDK 的 STM32 开发问题:重定向 printf 函数效果不生效(Keil MDK 中标准库未正确链接)
  • 基于springboot+vue的数字科技风险报告管理系统
  • 现代 JavaScript (ES6+) 入门到实战(一):告别 var!拥抱 let 与 const,彻底搞懂作用域
  • 领域驱动设计(DDD)【23】之泛化:从概念到实践
  • 网络缓冲区
  • DOP数据开放平台(真实线上项目)
  • 马斯克的 Neuralink:当意念突破肉体的边界,未来已来
  • Word之电子章制作——1
  • 【编译原理】期末
  • 华为云Flexus+DeepSeek征文|利用华为云一键部署的Dify平台构建高效智能电商客服系统实战
  • Youtube双塔模型
  • C++共享型智能指针std::shared_ptr使用介绍
  • cocos creator 3.8 - 精品源码 - 挪车超人(挪车消消乐)
  • Neo4j无法建立到 localhost:7474 服务器的连接出现404错误
  • Linux基本命令篇 —— less命令
  • springboot+Vue驾校管理系统
  • matplotlib 绘制水平柱状图
  • 基于LQR控制器的六自由度四旋翼无人机模型simulink建模与仿真
  • 使用deepseek制作“喝什么奶茶”随机抽签小网页
  • 我的世界模组开发进阶教程——机械动力的数据生成(2)