【智能协同云图库】智能协同云图库第二期:基于腾讯云 COS 对象存储—开发图片各功能模块
前言:在完成用户模块的基础架构后,我们正式进入图库系统的核心阶段。正如建筑需要先夯实地基再搭建主体, 这不仅是该项目的核心功能,也可以单独当做一个 图片分享平台 独立学习,本节重点在于基于腾讯云 COS 对象存储文件上传下载功能的开发,希望可以帮到大家😘
摘要
本文围绕图库系统核心功能开发,介绍基于腾讯云COS的图片上传与管理实现。先明确需求(功能暂限管理员使用),再进行方案设计(含picture表结构、对比传统存储与对象存储),随后详述后端开发:对象存储初始化、通用类与接口编写,图片上传及增删改查等功能开发,还有预置标签和分类接口实现,为核心功能提供完整指南。
文章超详细思维导图
一、需求分析
在设计时,图片上传,管理,图片修改功能暂时仅限管理员使用,以保证系统的安全性和稳定性。
基于这一原则,我们将优先实现以下功能,并按优先级排列如下:
二、方案设计
方案设计阶段我们需要确认:
-
库表设计
-
如何实现图片上传和下载?
-
创建图片的业务流程
-
如何解析图片的信息?
库表设计🔍
表名:picture(图片表),根据需求可以做出如下 SQL 设计:
-- 图片表
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;
✅几个注意事项:
1)字段设计:
-
基础信息:包括图片的 URL、名称、简介、分类等,满足图片管理和分类筛选的基本需求。
-
图片属性:记录图片大小、分辨率,宽高比和格式,方便后续按照多种维度筛选图片。
-
用户关联:通过
userId
字段关联用户表,表示由哪个用户创建了该图片。 -
多个标签:由于标签支持多个值,使用 JSON 数组字符串来维护,而不是单独新建一个表,可以提高开发效率。示例格式:
["标签1", "标签2"]
2)时间字段区分:
-
updateTime:任何修改都会触发数据库更新,便于记录最新变动。该字段可以不让用户看到
-
editTime:记录图片信息被编辑时间,需要通过业务逻辑主动更新。该字段可以对用户公开
3)索引设计:为高频查询的字段添加索引,提高查询效率。
4)逻辑删除:使用 isDelete
字段标记是否删除,避免直接删除数据导致数据不可恢复问题
✅扩展知识:
该原则出自黑马程序员精通MYSQL教程
如何实现图片上传和下载?
🚫 传统方案:服务器本地存储
// 伪代码示例:SpringBoot本地文件存储
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {file.transferTo(new File("/server/images/" + filename)); // 写入本地磁盘return "success";
}
致命缺陷:
-
存储瓶颈:单机磁盘容量上限,无法水平扩展
-
迁移灾难:服务器更换需全量文件迁移
-
安全裸奔:未隔离用户文件与系统文件,易遭路径遍历攻击(如
../../etc/passwd
) -
性能塌陷:高并发下载时磁盘IO成为瓶颈
因此,除了存储一些需要清理的临时文件之外,我们通常不会将用户上传并保存的文件(比如用户头像和图片)直接上传到服务器,而是更推荐大家使用专业的第三方存储服务,专业的工具做专业的事。其中,最常用的便是 对象存储 。
推荐大家优先用第三方云服务(比如阿里云OSS和腾讯云 COS)。本项目使用腾讯云 COS ,支持通过控制台、API、SDK 等多种方式接入,方便进行文件的上传、下载和管理。
创建图片的业务流程
创建图片其实包括了 2 个过程:上传图片文件
+ 补充图片信息
+ 保存到数据库中
有 2 种常见的处理方式:
先上传再提交数据
:- 用户直接上传图片,系统
生成图片的存储 URL
; - 然后在用户填写其他相关信息并提交后,才保存图片记录到数据库中
- 用户直接上传图片,系统
上传图片时直接保存记录
:- 在用户上传图片后,系统立即
生成图片的完整数据记录
- 无需等待用户点击提交,图片信息就立刻存入了数据库中。
- 之后用户再填写其他图片信息,相当于编辑了已有图片记录的信息。
- 在用户上传图片后,系统立即
两种方案的优缺点:
-
方案 1 的优点是流程简单,但缺点是
如果用户不提交,图片会残留在存储中
,导致空间浪费; -
方案 2 则可以理解为保存了
“图片稿草”
,即使用户不填写任何额外信息,也能找到之前的创建记录
。
三、后端开发
先准备好项目所需的依赖 —— 对象存储,然后再开发服务和接口。
创建并使用对象存储
首先进入对象存储的控制台,创建存储桶。
可以把存储桶理解为一个存储空间,和文件系统类似,都是根据路径找到文件或目录。
点击创建存储桶,注意地域选择国内。此处访问权限先选择“公有读私有写”,因为我们的存储桶要存储允许用户公开访问图片。而如果整个存储桶的文件都不允许用户访问,建议选择私有读写。
默认告警一定要勾选!因为对象存储服务的存储和访问流量都是计费的,超限后我们要第一时间得到通知并进行相应的处理。
不过也不用太担心,自己做项目的话一般是没人攻击你的,而且对象存储很便宜,正常情况下消耗的费用寥寥无几。
后端操作对象存储
1、初始化客户端
参考官方文档,我们要先初始化一个 COS 客户端对象,和对象存储服务进行交互
1)引入 COS 依赖:
<!-- 腾讯云 cos 服务 -->
<dependency> <groupId>com.qcloud</groupId> <artifactId>cos_api</artifactId> <version>5.6.227</version>
</dependency>
2)在项目 config
包下新建 CosClientConfig
类。负责读取配置,并创建一个 COS 客户端的 Bean。代码如下:
@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; @Bean public 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); }
}
3)填写配置文件
一定要注意防止密码泄露! 所以我们新建
application-local.yml
文件,并且在.gitignore
中忽略该文件的提交,这样就不会将代码等敏感配置提交到代码仓库了。
# 对象存储配置(需要从腾讯云获取)
cos:client:host: xxx #存储桶域名secretId: xxx #账号secretKey: xxx #密钥region: xxx #地域名bucket: xxx #存储桶名
2、通用能力类
在 manager
包下新建 CosManager
类,提供通用的对象存储操作,比如文件上传、文件下载等。
该类需要引入对象存储配置和 COS 客户端,用于和 COS 进行交互。
代码如下:
@Component
public class CosManager { @Resource private CosClientConfig cosClientConfig; @Resource private COSClient cosClient; // ... 一些操作 COS 的方法
}
3、文件上传
参考 官方文档 的“上传对象”部分,可以编写出文件上传的代码。
1)CosManager
新增上传对象的方法,代码如下:
/** * 上传对象 * * @param key 唯一键 * @param file 文件 */
public PutObjectResult putObject(String key, File file) { PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file); return cosClient.putObject(putObjectRequest);
}
2)为了方便测试,在 FileController
中编写测试文件上传接口。
需要注意,测试接口一定要加上管理员权限!防止任何用户随意上传文件。
测试文件上传接口代码如下:
/** * 测试文件上传 * * @param multipartFile * @return */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@PostMapping("/test/upload")
public BaseResponse<String> testUploadFile(@RequestPart("file") MultipartFile multipartFile) { // 文件目录 String filename = multipartFile.getOriginalFilename(); String filepath = String.format("/test/%s", filename); File file = null; try { // 上传文件 file = File.createTempFile(filepath, null); multipartFile.transferTo(file); cosManager.putObject(filepath, file); // 返回可访问地址 return ResultUtils.success(filepath); } catch (Exception e) { log.error("file upload error, filepath = " + filepath, e); throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败"); } finally { if (file != null) { // 删除临时文件 boolean delete = file.delete(); if (!delete) { log.error("file delete error, filepath = {}", filepath); } } }
}
4)测试接口
如果上传报错,错误如下:
org.springframework.web.multipart.MaxUploadSizeExceededException:
Maximum upload size exceeded
这个错误表明您的Spring Boot应用在上传文件时触发了 本地文件大小限制,而不是腾讯云COS的限制。错误信息显示最大上传大小被限制为 1048576字节(1MB)
🔧 解决方案:修改Spring Boot上传限制
在 application.yml
中添加:
spring:servlet:multipart:max-file-size: 20MBmax-request-size: 100MBenabled: true # 显式启用
4、文件下载
📦 1. 后端下载到服务器(服务端处理)
// 下载文件到本地
File localFile = new File("local.jpg");
GetObjectRequest request = new GetObjectRequest(bucketName, objectKey);
cosClient.getObject(request, localFile);// 然后对文件进行处理(如压缩/水印)
ImageUtils.addWatermark(localFile, "机密");
适用场景:
-
需服务器处理文件(加水印/格式转换)
-
文件需二次加工后返回用户
缺点:增加服务器负载,适合小文件
🌊 2. 获取文件流返回前端(流式传输)
// 获取输入流直接返回
COSObject cosObject = cosClient.getObject(bucketName, objectKey);
InputStream inputStream = cosObject.getObjectContent();// 设置响应头
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "attachment;filename=image.jpg");// 流拷贝(避免内存溢出)
IOUtils.copy(inputStream, response.getOutputStream());
适用场景:
-
需权限校验的中等文件(<100MB)
-
避免本地落盘,减少磁盘IO
关键点:用流拷贝而非完整加载到内存
🔗 3. 直接URL访问(前端直读,最简单)
String publicUrl = "https://{bucket}.cos.{region}.myqcloud.com/{key}";
// 返回给前端:<img src={publicUrl}>
前提:存储桶设置为公有读
风险:任何人都可访问
对于该项目,图片本身就是公开的,直接使用第三种方式,凭借 URL 连接访问即可。
1)首先在 CosManager
中新增对象下载方法,根据对象的 key 获取存储信息
/** * 下载对象 * * @param key 唯一键 */
public COSObject getObject(String key) { GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key); return cosClient.getObject(getObjectRequest);
}
2)为了方便测试,在 FileController
中编写测试文件下载接口
/** * 测试文件下载 * * @param filepath 文件路径 * @param response 响应对象 */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@GetMapping("/test/download/")
public void testDownloadFile(String filepath, HttpServletResponse response) throws IOException { COSObjectInputStream cosObjectInput = null; try { COSObject cosObject = cosManager.getObject(filepath); cosObjectInput = cosObject.getObjectContent(); // 处理下载到的流 byte[] bytes = IOUtils.toByteArray(cosObjectInput); // 设置响应头 response.setContentType("application/octet-stream;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=" + filepath); // 写入响应 response.getOutputStream().write(bytes); response.getOutputStream().flush(); } catch (Exception e) { log.error("file download error, filepath = " + filepath, e); throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败"); } finally { if (cosObjectInput != null) { cosObjectInput.close(); } }
}
测试成功
图片基础代码
首先利用 MyBatisX 插件生成图片表相关的基础代码,包括实体类、Mapper、Service。
用户模块中有讲解详细流程,此处不再赘述。
然后根据需求优化 Picture 实体类:
@TableName(value ="picture")
@Data
public class Picture implements Serializable { /** * 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; /** * 是否删除 */ @TableLogic private Integer isDelete; @TableField(exist = false) private static final long serialVersionUID = 1L;
}
图片上传
1、数据模型
在 model.dto.picture
下新建用于接受请求参数的类。由于图片需要支持重复上传(基础信息不变,只改变图片文件),所以要添加图片 id 参数:
@Data
public class PictureUploadRequest implements Serializable { /** * 图片 id(用于修改) */ private Long id; private static final long serialVersionUID = 1L;
}
在 model.vo
下新建上传成功后返回给前端的响应类,这是一个视图包装类,可以额外关联上传图片的用户信息。还可以编写 Picture 实体类和该 VO 类的转换方法,便于后续快速传值。
@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; /** * 创建用户信息 */ 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; }
}
注意:此处标签要转换为List集合返回给前端
2、通用文件上传服务
之前虽然我们已经编写了通用的对象存储操作类 CosManager,但这个类并不能直接满足我们的图片上传需求。
-
图片是否符合要求?需要校验
-
将图片上传到哪里?需要指定路径
-
如何解析图片?需要使用数据万象服务
所以,可以针对我们的项目,编写一个更贴合业务的文件上传服务 FileManager,该服务提供一个上传图片并返回图片解析信息的方法。
@Service
@Slf4j
public class FileManager { @Resource private CosClientConfig cosClientConfig; @Resource private CosManager cosManager; // ...
}
1)在 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 中添加上传图片并解析图片的方法:
/** * 上传对象(附带图片信息) * * @param key 唯一键 * @param file 文件 */
public PutObjectResult putPictureObject(String key, File file) { PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key, file); // 对图片进行处理(获取基本信息也被视作为一种处理) PicOperations picOperations = new PicOperations(); // 1 表示返回原图信息 picOperations.setIsPicInfo(1); // 构造处理参数 putObjectRequest.setPicOperations(picOperations); return cosClient.putObject(putObjectRequest);
}
3)在 FileManager 中编写上传图片的方法:
/** * 上传图片 * * @param multipartFile 文件 * @param uploadPathPrefix 上传路径前缀 * @return */
public UploadPictureResult uploadPicture(MultipartFile multipartFile, String uploadPathPrefix) { // 校验图片 validPicture(multipartFile); // 图片上传地址 String uuid = RandomUtil.randomString(16); String originFilename = multipartFile.getOriginalFilename(); String uploadFilename = String.format("%s_%s.%s", DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originFilename)); String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename); File file = null; try { // 创建临时文件 file = File.createTempFile(uploadPath, null); multipartFile.transferTo(file); // 上传图片 PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file); ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo(); // 封装返回结果 UploadPictureResult uploadPictureResult = new UploadPictureResult(); int picWidth = imageInfo.getWidth(); int picHeight = imageInfo.getHeight(); double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue(); uploadPictureResult.setPicName(FileUtil.mainName(originFilename)); uploadPictureResult.setPicWidth(picWidth); uploadPictureResult.setPicHeight(picHeight); uploadPictureResult.setPicScale(picScale); uploadPictureResult.setPicFormat(imageInfo.getFormat()); uploadPictureResult.setPicSize(FileUtil.size(file)); uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + uploadPath); return uploadPictureResult; } catch (Exception e) { log.error("图片上传到对象存储失败", e); throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败"); } finally { this.deleteTempFile(file); }
} /** * 校验文件 * * @param multipartFile multipart 文件 */
public void validPicture(MultipartFile multipartFile) { ThrowUtils.throwIf(multipartFile == null, ErrorCode.PARAMS_ERROR, "文件不能为空"); // 1. 校验文件大小 long fileSize = multipartFile.getSize(); final long ONE_M = 1024 * 1024L; ThrowUtils.throwIf(fileSize > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2M"); // 2. 校验文件后缀 String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename()); // 允许上传的文件后缀 final List<String> ALLOW_FORMAT_LIST = Arrays.asList("jpeg", "jpg", "png", "webp"); ThrowUtils.throwIf(!ALLOW_FORMAT_LIST.contains(fileSuffix), ErrorCode.PARAMS_ERROR, "文件类型错误");
} /** * 删除临时文件 */
public void deleteTempFile(File file) { if (file == null) { return; } // 删除临时文件 boolean deleteResult = file.delete(); if (!deleteResult) { log.error("file delete error, filepath = {}", file.getAbsolutePath()); }
}
3、服务开发
在 PictureService 中编写上传图片的方法:
接口:
/** * 上传图片 * * @param multipartFile * @param pictureUploadRequest * @param loginUser * @return */
PictureVO uploadPicture(MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, User loginUser);
实现类:
@Override
public PictureVO uploadPicture(MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, User loginUser) { ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR); // 用于判断是新增还是更新图片 Long pictureId = null; if (pictureUploadRequest != null) { pictureId = pictureUploadRequest.getId(); } // 如果是更新图片,需要校验图片是否存在 if (pictureId != null) { boolean exists = this.lambdaQuery() .eq(Picture::getId, pictureId) .exists(); ThrowUtils.throwIf(!exists, ErrorCode.NOT_FOUND_ERROR, "图片不存在"); } // 上传图片,得到信息 // 按照用户 id 划分目录 String uploadPathPrefix = String.format("public/%s", loginUser.getId()); UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix); // 构造要入库的图片信息 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()); picture.setUserId(loginUser.getId()); // 如果 pictureId 不为空,表示更新,否则是新增 if (pictureId != null) { // 如果是更新,需要补充 id 和编辑时间 picture.setId(pictureId); picture.setEditTime(new Date()); } boolean result = this.saveOrUpdate(picture); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败"); return PictureVO.objToVo(picture);
}
4、接口开发
在 PictureController 中编写上传图片接口,注意仅管理员可用:
/** * 上传图片(可重新上传) */
@PostMapping("/upload")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<PictureVO> uploadPicture( @RequestPart("file") MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, HttpServletRequest request) { User loginUser = userService.getLoginUser(request); PictureVO pictureVO = pictureService.uploadPicture(multipartFile, pictureUploadRequest, loginUser); return ResultUtils.success(pictureVO);
}
图片管理
1、数据模型
1)图片更新请求,给管理员使用。注意要将 tags 的类型改为 List<String>
,便于前端上传:
@Data
public class PictureUpdateRequest 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;
}
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)
@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 中编写判断用户是否为管理员的方法,后续开发中会用到
接口代码:
/** * 是否为管理员 * * @param user * @return */
boolean isAdmin(User user);
实现类:
@Override
public boolean isAdmin(User user) { return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole());
}
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; } // 从对象中取值 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)) { // 需要拼接查询条件 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)) { for (String tag : tags) { queryWrapper.like("tags", "\"" + tag + "\""); } } // 排序 queryWrapper.orderBy(StrUtil.isNotEmpty(sortField), sortOrder.equals("ascend"), sortField); return queryWrapper;
}
上面的代码中,注意两点:
searchText 支持同时从 name 和 introduction 中检索,可以用 queryWrapper 的 or 语法构造查询条件。
由于 tags 在数据库中存储的是 JSON 格式的字符串,如果前端要传多个 tag(必须同时存在才查出),需要遍历 tags 数组,每个标签都使用 like 模糊查询,将这些条件组合在一起。
SELECT * FROM picture
WHERE JSON_CONTAINS(tags, 'chan');
需要在程序中编写 MyBatis 的自定义 SQL 实现。
3)编写获取图片封装的方法,可以为原有的图片关联创建用户的信息。
获取单个图片封装:
@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;
}
分页获取图片封装:
/** * 分页获取图片封装 */
@Override
public Page<PictureVO> getPictureVOPage(Page<Picture> picturePage, HttpServletRequest request) { List<Picture> pictureList = picturePage.getRecords(); Page<PictureVO> pictureVOPage = new Page<>(picturePage.getCurrent(), picturePage.getSize(), picturePage.getTotal()); if (CollUtil.isEmpty(pictureList)) { return pictureVOPage; } // 对象列表 => 封装对象列表 List<PictureVO> pictureVOList = pictureList.stream().map(PictureVO::objToVo).collect(Collectors.toList()); // 1. 关联查询用户信息 Set<Long> userIdSet = pictureList.stream().map(Picture::getUserId).collect(Collectors.toSet()); Map<Long, List<User>> userIdUserListMap = userService.listByIds(userIdSet).stream() .collect(Collectors.groupingBy(User::getId)); // 2. 填充信息 pictureVOList.forEach(pictureVO -> { Long userId = pictureVO.getUserId(); User user = null; if (userIdUserListMap.containsKey(userId)) { user = userIdUserListMap.get(userId).get(0); } pictureVO.setUser(userService.getUserVO(user)); }); 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、接口开发
上述功能其实都是样板代码,俗称 “增删改查”。
代码实现比较简单,注意添加对应的权限注解、做好参数校验即可:
/** * 删除图片 */
@PostMapping("/delete")
public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { if (deleteRequest == null || deleteRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); long id = deleteRequest.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.removeById(id); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); 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 转为 string picture.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 获取图片(仅管理员可用) */
@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 获取图片(封装类) */
@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 转为 string picture.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 的类型不同,需要手动转换:
// 在此处将实体类和 DTO 进行转换
Picture picture = new Picture();
BeanUtils.copyProperties(pictureEditRequest, picture);
// 注意将 list 转为 string
picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));
获取预置标签和分类
根据需求,要支持用户根据标签和分类搜索图片,我们可以给用户列举一些常用的标签和分类,便于筛选。
在项目前期规模不大的时候,我们没必要将标签和分类单独用数据表来维护了,直接在 PictureController 中写一个接口,返回预设的固定数据即可:
@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);
}
随着系统规模和数据不断扩大,可以再改为使用配置中心或数据库动态管理这些数据,或者通过定时任务计算出热门的图片分类和标签。
至此,图片相关的后端接口开发完毕,大功告成!🎉🎉🎉