【智能协同云图库】智能协同云图库第四弹:实现用户上传图片及审核功能、使用模板方法模式优化上传图片功能(文件上传、URL 上传)、使用 Jsoup 实现批量抓取和创建图片功能
智能协同云图库第四弹:实现用户上传图片及审核功能、使用模板方法模式开发和优化上传图片功能(文件上传、URL 上传)、实现批量抓取和创建图片功能
本节重点
之前为了方便和安全性,只支持管理员上传图片。如果想让平台素材更丰富,也要允许用户自主上传图片。本节我们就重点开发用户传图能力,并支持更多传图的方式。
大纲:
- 支持用户上传图片和审核功能
- 通过 URL 导入图片
- 批量抓取和创建图片
一、用户上传图片及审核
需求分析
之前我们已经开发了管理员上传图片功能,想实现用户上传图片就比较简单了,但是我们要考虑到一点:“用户上传的内容可能是不安全的”。
一般只要涉及到“用户上传内容”(俗称 UGC)
的场景,就要增加审核功能
。
具体分析每个需求:
-
用户上传创建图片:
- 需要开放权限,允许用户上传图片。
- 功能和流程,跟之前管理员上传图片一致。
- 也要增加文件校验。
-
管理员审核图片:
- 管理员可以查看和筛选,所有
待审核
的图片。 - 并标记为
通过或拒绝
,可填写通过或拒绝的具体原因。 - 此外,需要记录
审核人和审核时间
作为日志,如果发现误审
的情况也可以追责
。
- 管理员可以查看和筛选,所有
方案设计
1. 审核逻辑
-
管理员可以操作审核的
状态流转
:- 默认为
“待审核”
,可以设置为“审核通过”或“审核拒绝”。 - 已拒绝的图片,可以重新审核为通过。
- 已通过的图片,可以撤销为拒绝状态。
- 默认为
-
管理员自动审核:
管理员上传/更新图片
时,图片自动审核通过
,并且自动填充审核参数
——设置审核人为创建人、审核时间为当前时间、审核原因为“管理员自动过审
”。
-
用户操作需要审核:
- 用户
上传或编辑图片
时,图片的状态会被重置为“待审核”
。 - 重复审核时,既可以选择
重置所有审核参数
,也可以仅重置审核状态
。 - 其余参数
在前端不展示,但是在后端保留
,以便管理员参考历史审核信息
。
- 用户
-
控制内容可见性:
- 对于
用户
来说,应该只能看见“审核通过”状态的数据
。 管理员
可以在图片管理页面
看到所有数据
,并且根据审核状态筛选图片
。
- 对于
Q:是否要考虑并发问题呢?
A: 由于审核操作为管理员手动执行,不涉及复杂的奖励机制或并发高频请求
,误审核或重复审核对系统影响不大,因此无需过度考虑并发问题。
2. 库表设计
为了支持审核功能,我们在 picture
图片表中新增审核相关字段,同时优化索引设计以提升查询性能。修改表的 SQL 如下:
ALTER TABLE picture
-- 添加新列
ADD COLUMN reviewStatus INT DEFAULT 0 NOT NULL COMMENT '审核状态:0-待审核; 1-通过; 2-拒绝',
ADD COLUMN reviewMessage VARCHAR(512) NULL COMMENT '审核信息',
ADD COLUMN reviewerId BIGINT NULL COMMENT '审核人 ID',
ADD COLUMN reviewTime DATETIME NULL COMMENT '审核时间';
-- 创建基于 reviewStatus 列的索引
CREATE INDEX idx_reviewStatus ON picture (reviewStatus);
注意事项:
- 审核状态:
reviewStatus
使用整数(0、1、2)表示不同的审核状态,而不是用字符串,可以节约表的空间、提升查找效率。 - 索引设计:由于要根据审核状态筛选图片,所以给该字段添加索引,提升查询性能。
后端开发
1. 数据模型开发
由于新增了一些审核相关的字段,要对原有的数据模型(实体类、包装类等)进行修改。
(1)实体类 Picture
新增
/**
* 状态:0-待审核; 1-通过; 2-拒绝
*/
private Integer reviewStatus;
/**
* 审核信息
*/
private String reviewMessage;
/**
* 审核人 id
*/
private Long reviewerId;
/**
* 审核时间
*/
private Date reviewTime;
(2)图片查询请求类 PictureQueryRequest
新增:
/**
* 状态:0-待审核; 1-通过; 2-拒绝
*/
private Integer reviewStatus;
/**
* 审核信息
*/
private String reviewMessage;
/**
* 审核人 id
*/
private Long reviewerId;
(3)新建审核状态枚举类
:
@Getter
public enum PictureReviewStatusEnum {
REVIEWING("待审核", 0),
PASS("通过", 1),
REJECT("拒绝", 2);
private final String text;
private final int value;
// 1. 枚举类属性 text : String 对应的 value: 从 String 转为 int
PictureReviewStatusEnum(String text, int value) {
this.text = text;
this.value = value;
}
/**
* 根据 value 获取枚举
*/
public static PictureReviewStatusEnum getEnumByValue(Integer value) {
// 2. 如果传入的枚举参数 value == null , 返回 null
if (ObjUtil.isEmpty(value)) {
return null;
}
// 3. 参数不为 null, 遍历所有枚举状态码, 找到 value 对应的 text
for (PictureReviewStatusEnum pictureReviewStatusEnum : PictureReviewStatusEnum.values()) {
// 4. int 和 Integer 不用 equals, 会自动拆箱判断
if (pictureReviewStatusEnum.value == value) {
return pictureReviewStatusEnum;
}
}
return null;
}
}
2. 管理员审核功能
(1)开发请求包装类
开发请求包装类,注意不需要增加 reviewerId
和 reviewTime
字段,这两个是由系统自动填充的,而不是由前端传递。
@Data
public class PictureReviewRequest implements Serializable {
/**
* id
*/
private Long id;
/**
* 状态:0-待审核, 1-通过, 2-拒绝
*/
private Integer reviewStatus;
/**
* 审核信息
*/
private String reviewMessage;
private static final long serialVersionUID = 1L;
}
(2)开发审核服务接口
对于该接口,执行的结果为审核成功和审核失败,审核失败会直接抛异常,因此接口的返回值类型设置为 void:
/**
* 图片审核
*
* @param pictureReviewRequest 审核请求类
* @param loginUser 当前登录用户
*/
void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser);
实现类:
@Override
public void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) {
// 1. 校验参数
ThrowUtils.throwIf(pictureReviewRequest == null, ErrorCode.PARAMS_ERROR);
// 2. pictureReviewRequest.allget()
Long id = pictureReviewRequest.getId();
Integer reviewStatus = pictureReviewRequest.getReviewStatus();
// 3. 从当前图片审核请求中获取状态码, 然后根据该状态码从枚举类中获取对应枚举字段的 text 属性
PictureReviewStatusEnum reviewStatusEnum = PictureReviewStatusEnum.getEnumByValue(reviewStatus);
String reviewMessage = pictureReviewRequest.getReviewMessage();
// 4. 校验审核请求
if(id == null || reviewStatusEnum == null || PictureReviewStatusEnum.REVIEWING.equals(reviewStatusEnum)){
// 第三个校验条件为, 当前图片是待审核状态???
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 5. 判断图片是否存在
Picture oldPicture = this.getById(id);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
// 6. 校验审核状态是否重复
if(oldPicture.getReviewStatus().equals(reviewStatus)){
// 7. 数据库存储的图片状态, 于当前发送审核请求的图片对应的状态相同, 则不能再次调用审核操作, 抛异常
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请勿重复审核");
}
// 8. 数据库操作
// 9. 不能直接以 oldPicture 对象作为更新对象, 而是要重新创建一个新对象
Picture updatePicture = new Picture();
BeanUtil.copyProperties(pictureReviewRequest, updatePicture); // BeanUtil 包用 hutool / spring 都可
// 11. 手动填充图片的审核人 id 和审核的时间
updatePicture.setReviewerId(loginUser.getId());
updatePicture.setReviewTime(new Date());
// 10. 因为 mybatis 的 updateById() 会根据 id 更新有值的属性, 以 oldPicture 为更新对象, 会重新更新所有字段的值
boolean result = this.updateById(updatePicture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}
(3)开发审核接口
开发审核接口,注意权限设置为仅管理员可用:
/**
* 图片审核功能
* @param pictureReviewRequest 图片审核请求
* @param request 当前请求
* @return 各种审核失败的结果都会抛异常, 否则返回 true
*/
@PostMapping("/review")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> doPictureReview(@RequestBody PictureReviewRequest pictureReviewRequest,
HttpServletRequest request){
ThrowUtils.throwIf(pictureReviewRequest == null , ErrorCode.PARAMS_ERROR);
User loginUser = userService.getLoginUser(request);
pictureService.doPictureReview(pictureReviewRequest, loginUser);
return ResultUtils.success(true);
}
3. 审核状态设置
(1) 权限控制
首先取消上传图片接口(uploadPicture
)的权限校验注解
/**
* 上传图片(可重新上传)
*/
@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);
}
但是注意,由于图片上传功能是支持图片编辑的,所以需要做好编辑权限控制 —— 仅本人或管理员可编辑。
修改 PictureService
的 uploadPicture
方法,补充权限校验逻辑:
// 如果是更新图片,需要校验图片是否存在
if (pictureId != null) {
Picture oldPicture = this.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 仅本人或管理员可编辑
if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
修改后的 uploadPicture()
@Override
public 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. 如果传入的请求不为空, 才获取请求中的图片 ID
pictureId = pictureUploadRequest.getId();
}
// 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID
if (pictureId != null) {
// boolean exists = this.lambdaQuery()
// .eq(Picture::getId, pictureId)
// .exists();
// // 5. 如果数据库中没有图片, 则抛异常, 因为这是更新图片的接口
// ThrowUtils.throwIf(!exists, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 修改 1: 之前只是判断图片是否存在, 现在要加上查找图片的审核状态
Picture oldPicture = this.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 修改 2: 仅本人和管理员可以编辑图片
// Long、Integer 类型包装类最好也用 equals 判断
if(!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)){
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
// 7. 定义上传文件的前缀 public/登录用户 ID
String 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());
picture.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);
}
(2) 设置审核状态
管理员自动过审并且填充审核参数
;
用户上传
或编辑图片
时,图片的状态会被重置为“待审核”
。
由于图片上传、用户编辑、管理员更新
这3个操作都需要设置审核状态
,所以我们可以先编写一个通用的“补充审核参数”
的方法
根据用户的角色
,给图片对象
填充审核字段
的值。
/**
* 填充审核参数接口
* @param picture
* @param loginUser
*/
@Override
public void fillReviewParams(Picture picture, User loginUser){
if(userService.isAdmin(loginUser)){
// 1. 管理员自动过审
picture.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
picture.setReviewerId(loginUser.getId());
picture.setReviewMessage("管理员自动过审");
picture.setReviewTime(new Date());
}else {
// 2. 非管理员, 无论编辑图片还是创建图片, 都默认图片审核状态为待审核
picture.setReviewStatus(PictureReviewStatusEnum.REVIEWING.getValue());
}
}
接下来,分别给3个操作
补充审核参数(在接口执行数据库操作前,补充审核参数
):
为图片更新接口补充审核参数
public BaseResponse<Boolean> updatePicture(@RequestBody PictureUpdateRequest pictureUpdateRequest, HttpServletRequest request) {
// ...
Picture oldPicture = pictureService.getById(id);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
// 补充审核参数
User loginUser = userService.getLoginUser(request);
pictureService.fillReviewParams(picture, loginUser);
// 操作数据库
boolean result = pictureService.updateById(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(true);
}
为图片修改接口补充审核参数
public BaseResponse<Boolean> editPicture(@RequestBody PictureEditRequest pictureEditRequest, HttpServletRequest request) {
// ...
if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 补充审核参数
pictureService.fillReviewParams(picture, loginUser);
// 操作数据库
boolean result = pictureService.updateById(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(true);
}
为上传图片服务补充审核参数
@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {
// ...
picture.setPicFormat(uploadPictureResult.getPicFormat());
picture.setUserId(loginUser.getId());
// 补充审核参数
fillReviewParams(picture, loginUser);
// 如果 pictureId 不为空,表示更新,否则是新增
if (pictureId != null) {
// 如果是更新,需要补充 id 和编辑时间
picture.setId(pictureId);
picture.setEditTime(new Date());
}
// ...
}
4. 控制内容可见性
目前我们只有主页给用户查看图片列表,所以需要修改主页调用的 listPictureVOByPage
接口,补充查询条件即可,默认只能查看已过审的数据:
关键修改位置的代码:
// 普通用户默认只能查看已过审的数据
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());
// 查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
pictureService.getQueryWrapper(pictureQueryRequest));
需要同步更改 PictureService
的 getQueryWrapper
方法,支持根据审核字段进行查询:
Integer reviewStatus = pictureQueryRequest.getReviewStatus();
String reviewMessage = pictureQueryRequest.getReviewMessage();
Long reviewerId = pictureQueryRequest.getReviewerId();
queryWrapper.eq(ObjUtil.isNotEmpty(reviewStatus), "reviewStatus", reviewStatus);
queryWrapper.like(StrUtil.isNotBlank(reviewMessage), "reviewMessage", reviewMessage);
queryWrapper.eq(ObjUtil.isNotEmpty(reviewerId), "reviewerId", reviewerId);
这样一来,后端就同时支持了“管理员筛选审核状态”的功能。
至此,用户上传图片及审核的后端就开发完成了。
Q:根据 id 查询图片的接口需要做同样的限制么?
A:对目前咱们的系统来说,用户正常情况下不会得到未过审图片的 id,影响面较小,可以暂时不做,感兴趣的同学按需优化即可。
拓展
1、更多审核策略
在实际企业中,为了提高审核效率、减少垃圾内容,同时保证用户体验和平台的安全性,常常会结合技术手段和业务策略来优化审核流程。比如下面几点,大家可以按需扩展:
- 内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。
- AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。
- 分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。
- 实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。
- 举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。
2、审核通知
当管理员完成审核后,系统可以通过消息中心或邮件通知用户审核结果。
二、通过 URL 导入图片
需求分析
为了提高上传图片的效率,除了支持上传本地文件外,还可以支持输入一个远程 URL,直接将网上已有的图片导入到我们的系统中。
方案设计
实现原理很简单,但是有一些细节需要注意:
(1)下载图片
后端服务器从指定的远程 URL 下载图片到本地临时存储。
对于 Java 项目,可以直接使用 Hutool 的 HttpUtil.downloadFile
方法一行代码完成。
(2)校验图片
跟验证本地文件一样,需要校验图片的格式、大小等。传统的校验思路是先把文件下载到本地,再对本地文件进行校验,有没有更节省资源的方法呢?其实可以先对 URL 本身进行校验。
- 首先是校验 URL 字符串本身的合法性,比如要是一个合理的 URL 地址。
- 此外,可以先使用
HEAD 请求
来获取 URL 对应文件的元信息(如文件大小、格式等)。 HEAD 请求仅返回 HTTP 响应头信息,而不会下载文件的内容,大大降低了网络流量的消耗
。- 注意此处
不能使用 GET 请求,它会获取完整文件
。
(3)上传图片
- 将校验通过的图片上传到对象存储服务,生成存储 URL。
- 之后的流程就都可以复用从本地上传图片的流程了。
后端开发
1、服务开发
先编写通过 URL 上传文件的方法,为了便于开发,直接在 FileManager
类中编写
绝大多数代码跟之前的 uploadPicture
方法一致,只需要改动以下 4 处位置:
- 方法接受的参数:之前是
MultipartFile
文件类型,现在是String
字符串类型 - 校验图片:之前是校验文件,现在是校验 URL
- 获取文件名称:之前是根据文件获取,现在是根据 URL 获取
- 保存临时文件:之前是将
MultipartFile
写入到临时文件,现在是从 URL 下载文件
代码如下:
/**
* 根据图片 url 上传文件
* @param fileUrl 上传的图片文件的 URL
* @param uploadPathPrefix 上传文件的路径前缀
* 由于这个方法是通用的上传图片文件的方法, 因此我们使用上传路径前缀, 而不是具体路径
* 具体的路径, 可解析上传文件的具体信息
* @return 上传图片后解析出的结果
*/
// 修改 1 : 修改方法名和方法参数
public UploadPictureResult uploadPictureByUrl(String fileUrl, String uploadPathPrefix){
// 1. 校验图片(校验逻辑比较复杂, 单独写一个方法)
// validPicture(multipartFile);
// todo
// 修改 2: 根据 fileUrl 校验图片, 原来是根据文件对象校验
validPicture(fileUrl);
// 2. 图片上传地址
String uuid = RandomUtil.randomString(16);
// 文件可以重名, 使用 UUID 标识不同的文件, RandomUtil 是 hutool 工具类, 生成 16 位唯一且随机字符串
// String originalFilename = multipartFile.getOriginalFilename();
// todo
// 修改 3. 根据 URL 后缀获取文件原始名称, 而不是根据文件对象获取文件名
String originalFilename = FileUtil.mainName(fileUrl);
// FileUtil 是 hutool 工具类, 通过 url 后缀获取文件名称
// 4. 确定最终上传文件的文件名: 创建文件时间戳-uuid-原始文件名后缀
String uploadFilename = String.format(%s_%s.%s,
DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originalFilename));
// format() 第一个参数是拼接的形式, %s_%s.%s 每一个 %s 都是一个需要拼接的字符串
// 最终文件上传名称由我们自己拼接, 不能用原始文件名, 可以提高安全性, 否则可能会导致 URL 冲突%s_%s.%s
// 5.确定最终上传文件的文件路径: /uploadPathPrefix/uploadFilename
String uploadPath = String.format("/%s/%s", uploadPathPrefix,uploadFilename);
// 最终路径: 文件前缀参数(可以由用户自己指定, 比如短视频放入不同收藏夹) + 拼接好的最终文件名称
// 6. 将文件上传到对象存储中(FileController 有现成代码)
File file = null;
try {
// 11. 将原来的 filepath 修改为 uploadPath
file = File.createTempFile(uploadPath, null);
// multipartFile.transferTo(file);
// todo
// 修改 4 , 使用 hutool 工具雷 HttpUtil, 先根据 fileUrl 将文件下载到本地
HttpUtil.downloadFile(fileUrl, file);
// 12. 获取上传结果对象
PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);
// 之前不需要获取, 是因为我们不需要解析文件信息, 现在获取结果对象, 方便后续对文件进行解析
// 13. 从文件的结果对象中, 获取文件的原始信息, 再从原始信息中获取图片对象
ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();
// 14. 封装返回结果
UploadPictureResult uploadPictureResult = new UploadPictureResult();
// uploadPictureResult.allset
uploadPictureResult.setUrl( cosClientConfig.getHost() + "/" + uploadPath); // 域名/上传路径 = 绝对路径
uploadPictureResult.setPicName(originalFilename);
uploadPictureResult.setPicSize(FileUtil.size(file));
// 15. 计算图片的宽、高、宽高比 imageInfo.allget
int 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
}
}
2、校验 URL 图片
编写校验 URL 图片的方法,分别校验 URL 格式、协议、文件是否存在、文件格式、文件大小。代码如下:
/**
* 修改 5 : 增加根据 url 校验文件的方法
* 重点: 仅对 url 能获取到的信息进行校验, 使用 try...finally... 强制校验结束释放资源
* @param fileUrl
*/
private void validPicture(String fileUrl) {
// 1. 校验非空
ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址为空");
// 2. 校验 url 格式
try {
new URL(fileUrl);
// 利用 JAVA 本身的 URL 对象, 来校验 fileUrl 是否可以被解析出来
// 需要自动 try catch 捕获异常
} catch (MalformedURLException e) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件地址格式不正确");
}
// 3. 校验 url 协议(前缀 http/https)
ThrowUtils.throwIf(
!fileUrl.startsWith("http://") && !fileUrl.startsWith("https://"),
ErrorCode.PARAMS_ERROR,
"仅支出 HTTP 或 HTTPS 协议的文件地址"
);
// 4. 发送 HEAD 请求验证图片是否存在
HttpResponse httpResponse = null;
try {
httpResponse = HttpUtil.createRequest(Method.HEAD, fileUrl).execute();
// hutool 工具类创建请求 , 以 fileUrl 为 url 发送一个 HEAD 方法的请求, 并接收响应结果
// 5. 校验 HEAD 请求的响应结果(校验响应状态码)
if(httpResponse.getStatus() != HttpStatus.HTTP_OK){
// 未正常返回, 无需执行其他判断
return;
// 不报错, 而是直接返回, 是因为有些浏览器不支持 HEAD 请求, 并不是要校验的文件不存在
}
// 7. 文件存在, 获取文件的类型用于后续校验
String contentType = httpResponse.header("Content-Type");
// 8. 文件类型存在, 才校验文件 url 类型是否合法
if(StrUtil.isNotBlank(contentType)){
// 允许的图片类型
final List<String> ALLOW_CONTENT_TYPES = Arrays.asList("image/jpeg", "image/jpg", "image/png", "image/webp");
// 当前图片类型, 不在允许的图片类型的列表中, 抛出文件类型错误的异常
ThrowUtils.throwIf(!ALLOW_CONTENT_TYPES.contains(contentType.toLowerCase()),
ErrorCode.PARAMS_ERROR, "文件类型错误");
}
// 9. 文件存在, 对文件大小进行校验
String contentLengthStr = httpResponse.header("Content-Length");
// 10. 文件大小存在, 才校验文件大小是否合法, 前面约定过, 文件大小最大不能超过 2 MB
if(StrUtil.isNotBlank(contentLengthStr)){
// 13. 点 parseLong() 源码, 发现会抛异常 NumberFormatException, 捕获该异常
try{
// 11. 将字符串转为 long 类型
long contentLength = Long.parseLong(contentLengthStr);
// 定义单位 MB
final long ONE_M = 1024*1024;
ThrowUtils.throwIf(contentLength > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2MB");
}catch (NumberFormatException e){
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式异常");
}
}
}finally {
// 6. 根据第五点, 有浏览器不支持 HEAD 请求, 直接返回
// 但是一定要释放资源, 所以使用 try....finally, 确保释放资源操作一定会被执行
if(httpResponse != null){
httpResponse.close();
}
}
}
注意
上述代码中,注意 2 点:
- 注意
发送 HTTP 请求后
,需要即时释放资源
。 有些 URL 地址可能不支持通过 HEAD 请求访问,为了提高导入成功率,即使 HEAD 请求访问失败,也不会报错,并且不用执行后续的校验。
仅对能获取到的信息进行校验。
3、优化代码 - 模板方法模式
目前我们的 FileManager
文件内写了两种不同的上传文件的方法,但是我们会发现,这两种方法的流程完全一致、而且大多数代码都是相同的。
这种情况下,我们就要想要运用设计模式 —— 模板方法模式 对代码进行优化。
模板方法模式是行为型设计模式,适用于具有通用处理流程、但处理细节不同的情况。
通过定义一个抽象模板类,提供通用的业务流程处理逻辑,并将不同的部分定义为抽象方法,由子类具体实现
。
在我们的场景中,两种文件上传方法的流程都是:
- 校验文件
- 获取上传地址
- 获取本地临时文件
- 上传到对象存储
- 封装解析得到的图片信息
- 清理临时文件
可以将这些流程抽象为一套模板(抽象类),将每个实现不一样的步骤都定义为一个抽象方法,比如:
- 校验图片
- 获取文件名称
- 保存临时文件
下面开始开发
先在 manager
包下新建 upload
包,将模板方法有关的代码全部放在该包下统一管理。
(1)新建图片上传模板抽象类 PictureUploadTemplate
复制一份写好的文件上传类,对已有代码进行修改重构,修改类名,并修改类声明为抽象类:
代码如下:
@Slf4j
@Service
public abstract class PictureUploadTemplate {
@Resource
private CosClientConfig cosClientConfig;
@Resource
private CosManager cosManager;
/**
*
* @param inputSource 输入源
* @param uploadPathPrefix 上传文件的路径前缀
* 由于这个方法是通用的上传图片文件的方法, 因此我们使用上传路径前缀, 而不是具体路径
* 具体的路径, 可解析上传文件的具体信息
* @return 上传图片后解析出的结果
*/
public UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix){
// 1. 校验图片
validPicture(inputSource);
// 2.图片上传地址
String uuid = RandomUtil.randomString(16);
String originalFilename = getOriginalFilename(inputSource);
String uploadFilename = String.format("%s_%s.%s",
DateUtil.formatDate(new Date()), uuid, FileUtil.getSuffix(originalFilename));
String uploadPath = String.format("/%s/%s", uploadPathPrefix,uploadFilename);
File file = null;
try {
// 3. 创建临时文件
file = File.createTempFile(uploadPath, null);
// 4. 处理文件来源
processFile(inputSource, file);
// 5. 上传文件到对象存储
PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);
// 6. 获取图片信息对象
ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();
// 7. 封装返回结果
return buildResult(originalFilename, file, uploadPath, imageInfo);
} catch (Exception e) {
log.error("图片上传到对象存储失败 " , e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
}finally {
// 8. 清理临时文件
deleteTempFile(file);
}
}
/**
* 封装返回结果
* @param originalFilename
* @param file
* @param uploadPath
* @param imageInfo 对象存储返回的图片信息
* @return
*/
private UploadPictureResult buildResult(String originalFilename, File file, String uploadPath, ImageInfo imageInfo) {
// 封装返回结果
UploadPictureResult uploadPictureResult = new UploadPictureResult();
uploadPictureResult.setUrl( cosClientConfig.getHost() + "/" + uploadPath);
uploadPictureResult.setPicName(originalFilename);
uploadPictureResult.setPicSize(FileUtil.size(file));
int picWidth = imageInfo.getWidth();
int picHeight = imageInfo.getHeight();
double picScale = NumberUtil.round(picWidth * 1.0/picHeight, 2).doubleValue();
uploadPictureResult.setPicWidth(picWidth);
uploadPictureResult.setPicHeight(picHeight);
uploadPictureResult.setPicScale(picScale);
uploadPictureResult.setPicFormat(imageInfo.getFormat());
return uploadPictureResult;
}
/**
* 校验输入源
*/
protected abstract void validPicture(Object inputSource);
/**
* 获取输入源的原始文件名
*/
protected abstract String getOriginalFilename(Object inputSource);
/**
* 处理输入源并生成本地文件
*/
protected abstract void processFile(Object inputSource, File file);
/**
* 删除临时文件
* @param file
*/
public void deleteTempFile(File file) {
if(file != null){
boolean deleteResult = file.delete();
if(!deleteResult){
log.error("file delete error, filepath = {}", file.getAbsoluteFile());
}
}
}
}
上述代码中,我们把每个步骤都封装为了一个单独的方法,公共的实现(比如 deleteTempFile
)可以直接放到模板中,而不用放到具体的实现类中。
注意,为了让模板同时兼容 MultipartFile
和 String
类型的文件参数,直接将这两种情况统一为 Object
类型的 inputSource
输入源。
(2)新建本地图片上传子类
FilePictureUpload
,继承模板
并且打上 @Service
注解生成 Bean 实例:
@Service
public class FilePictureUpload extends PictureUploadTemplate {
@Override
protected void validPicture(Object inputSource) {
MultipartFile multipartFile = (MultipartFile) inputSource;
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, "文件类型错误");
}
@Override
protected String getOriginFilename(Object inputSource) {
MultipartFile multipartFile = (MultipartFile) inputSource;
return multipartFile.getOriginalFilename();
}
@Override
protected void processFile(Object inputSource, File file) throws Exception {
MultipartFile multipartFile = (MultipartFile) inputSource;
multipartFile.transferTo(file);
}
}
(3)新建 URL 图片上传子类
UrlPictureUpload
,继承模板,并且打上 @Service
注解生成 Bean 实例:
@Service
public class UrlPictureUpload extends PictureUploadTemplate {
@Override
protected void validPicture(Object inputSource) {
String fileUrl = (String) inputSource;
ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址不能为空");
// ... 跟之前的校验逻辑保持一致
}
@Override
protected String getOriginFilename(Object inputSource) {
String fileUrl = (String) inputSource;
// 从 URL 中提取文件名
return FileUtil.mainName(fileUrl);
}
@Override
protected void processFile(Object inputSource, File file) throws Exception {
String fileUrl = (String) inputSource;
// 下载文件到临时目录
HttpUtil.downloadFile(fileUrl, file);
}
}
优化完后
可以还原 FileManager
文件,并添加 @Deprecated
注解表示已废弃,后续将直接使用文件上传模板类 PictureUploadTemplate
。
/**
* 文件服务
* @deprecated 已废弃,改为使用 upload 包的模板方法优化
*/
@Deprecated
4、图片上传服务支持 URL 上传
由于图片上传的逻辑还是比较复杂的,尽量让 URL 上传复用之前的代码。
但是之前图片上传服务的 uploadPicture
方法接受的是文件类型的参数:
现在要支持 URL 上传,怎么办呢?
可以将输入参数跟上述模板一样,改为 Object
类型的 inputSource
,然后在代码中可以根据 inputSource
的实际类型,来选择对应的图片上传子类。
修改接口参数:
修改实现类参数,并添加关于文件上传类、URL 上传类这两个类的 bean 对象
修改实现类报错的位置:
由于 fileManager
类已被废弃,现在改用 pictureUploadTemplate
抽象类对象来处理文件上传。根据输入源 inputSource
的类型,动态选择 pictureUploadTemplate
的具体实现类:
- 判断输入源类型:
- 如果
inputSource
是String
类型,则将pictureUploadTemplate
设置为其子类urlPictureUpload
的实例。 - 如果
inputSource
是MultipartFile
类型,则将pictureUploadTemplate
设置为其子类filePictureUpload
的实例。
- 如果
- 调用上传方法:
- 使用
pictureUploadTemplate
的uploadPicture
模板方法上传文件,并获取上传结果
- 使用
代码如下:
@Resource
private FilePictureUpload filePictureUpload;
@Resource
private UrlPictureUpload urlPictureUpload;
@Override
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {
// 1. 校验参数, 用户未登录, 抛出没有权限的异常
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
// 2. 判断是新增图片, 还是更新图片, 所以先判断图片是否存在
Long pictureId = null;
if (pictureUploadRequest != null) {
// 3. 如果传入的请求不为空, 才获取请求中的图片 ID
pictureId = pictureUploadRequest.getId();
}
// 4. 图片 ID 不为空, 查数据库中是否有对应的图片 ID
// 新增条件 pictureId > 0, 仅当有 id (id >0)才检查
// todo
if (pictureId != null && pictureId > 0) {
Picture oldPicture = this.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 修改 2: 仅本人和管理员可以编辑图片
// Long 类型包装类最好也用 equals 判断
if (!oldPicture.getUserId().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
// 7. 定义上传文件的前缀 public/登录用户 ID
String uploadPathPrefix = String.format("public/%s", loginUser.getId());
// 根据用户划分前缀, 当前的图片文件上传到公共图库, 因此前缀定义为 public
// 8. 上传图片, 上传图片 API 需要的参数(原始文件 + 文件前缀), 获取上传文件结果对象
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
if (inputSource instanceof String) {
pictureUploadTemplate = urlPictureUpload;
}
UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
// UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix);
// 9. 构造要入库的图片信息(样板代码)
Picture picture = new Picture();
picture.setUrl(uploadPictureResult.getUrl());
String picName = uploadPictureResult.getPicName();
if(pictureUploadRequest!=null && StrUtil.isNotBlank(pictureUploadRequest.getPicName())){
// 图片更新请求不为空, 并且图片更新请求中的图片名称属性不为空, 以更新请求的图片名称, 代替图片解析结果的名称
// pictureUploadRequest 的 PicName 属性是允许用户传递的
picName = pictureUploadRequest.getPicName();
}
picture.setName(picName);
picture.setPicSize(uploadPictureResult.getPicSize());
picture.setPicWidth(uploadPictureResult.getPicWidth());
picture.setPicHeight(uploadPictureResult.getPicHeight());
picture.setPicScale(uploadPictureResult.getPicScale());
picture.setPicFormat(uploadPictureResult.getPicFormat());
picture.setUserId(loginUser.getId());
this.fillReviewParams(picture, loginUser);
// 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);
}
💡 除了通过对象类型判断外,也可以通过传一个业务参数(如
type
)来区分不同的上传方式。
5、接口开发
1)在请求封装类 PictureUploadRequest
中新增 fileUrl
文件地址:
@Data
public class PictureUploadRequest implements Serializable {
/**
* 图片 id(用于修改)
*/
private Long id;
/**
* 文件地址
*/
private String fileUrl;
private static final long serialVersionUID = 1L;
}
2)在 PictureController
中新增接口,通过 URL 上传图片:
/**
* 通过 URL 上传图片(可重新上传)
*/
@PostMapping("/upload/url")
public BaseResponse<PictureVO> uploadPictureByUrl(
@RequestBody PictureUploadRequest pictureUploadRequest,
HttpServletRequest request) {
User loginUser = userService.getLoginUser(request);
String fileUrl = pictureUploadRequest.getFileUrl();
PictureVO pictureVO = pictureService.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
return ResultUtils.success(pictureVO);
}
然后可以通过 Swagger 接口文档测试本地文件图片
和 URL 图片
的上传
http://localhost:8123/api/doc.html
示例图片 URL:
https://www.codefather.cn/logo.png
三、批量抓取和创建图片
需求分析
为了帮助管理员快速丰富图片库,冷启动项目,需要提供批量从网络抓取并创建图片的功能。但是要注意,不建议将该功能开放给普通用户!
主要是为了防止滥用导致的版权问题、低质量内容的上传、服务器资源消耗和安全问题。
因为我们要从网络批量抓取图片(爬虫),如果功能开放给用户,相当于所有用户都在使用我们的服务器作为爬虫源头,容易导致我们的服务器 IP 被封禁。
方案设计
方案设计的重点包括:
- 如何抓取图片
- 抓取和导入规则
1、如何抓取图片?
思考 2 个问题:
- 从哪里抓取图片?
- 怎么抓取图片呢?
绝大多数的图片素材网站,都是有版权保护的,不建议大家操作,容易被封禁 IP 和账号。比较安全的方法是从搜索引擎中抓取图片,仅学习使用、不商用的话基本不会有什么风险。
这里我们选择从 Bing 搜索获取图片,首先进入 Bing 图片网站
,可以看到很多图片,但是如何获取这些图片呢?
有 2 种常见的做法:
请求到完整的页面内容后,对页面的 HTML 结构进行解析,提取到图片的地址,再通过 URL 下载。
直接调用后端获取图片地址的接口拿到图片数据。
要使用哪种方式,还是要具体情况具体分析。比如在调研过程中,我们会发现直接从 Bing 图片的首页抓取数据,可能会出现获取不到图片的情况。所以我们换一种策略,尝试去找图片接口。
按 F12 打开网络请求控制台,向下滚动图片时会触发新一波图片的加载,就能看到获取图片数据的接口了
:
https://cn.bing.com/images/async?q=%s&mmasync=1
注意,URL 地址必须要添加
mmasync=1
参数!否则加载条数不对。
但是该接口返回的还是 HTML 文档结构,所以我们需要使用一个 HTML 文档解析库来提取图片地址,Java 中比较推荐 jsoup,非常轻量。
jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。我们可以先通过类选择器找到最外层的元素 dgControl
,再通过 CSS 选择器 img.mimg
找到所有的图片元素:
注意,图片的地址后面有很多附加参数,比如 ?w=199&h=180
,在导入图片时一定要移除!
否则会影响图片的质量,还有可能导致上传到对象存储的文件包含被转义的特殊字符,引发无法访问等问题。
2、抓取和导入规则
可以在抓取时,让管理员填写以下参数:
- 搜索关键词:便于找到需要的数据
- 抓取数量:单次要抓取的条数,不建议超过 30 条(接口单次返回的图片有限)
后端开发
1、定义请求体
在 model.dto.picture
包下新建 PictureUploadByBatchRequest
:
@Data
public class PictureUploadByBatchRequest {
/**
* 搜索词
*/
private String searchText;
/**
* 抓取数量
*/
private Integer count = 10;
}
2、开发服务
(1)引入 jsoup 库
此处选 v1.15.3 版本,使用的人较多:
<!-- HTML 解析:https://jsoup.org/ -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
</dependency>
(2)编写批量抓取和创建图片方法接口
/**
* 批量抓取和创建图片
*
* @param pictureUploadByBatchRequest 批量抓取图片请求类
* @param loginUser 获取登录用户信息, 只有管理员才可以调用该接口
* @return 成功创建的图片数
*/
Integer uploadPictureByBatch(
PictureUploadByBatchRequest pictureUploadByBatchRequest,
User loginUser);
实现类:
@Override
public Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {
// 1. 获取参数 pictureUploadByBatchRequest.allget
String searchText = pictureUploadByBatchRequest.getSearchText();
Integer count = pictureUploadByBatchRequest.getCount();
// 2. 校验参数
ThrowUtils.throwIf(count > 30, ErrorCode.PARAMS_ERROR, "最多抓取 30 条");
// 3. 抓取内容
// 4. 拼接要抓取的 url, 其中参数 q=%s 是可以动态修改的, 考虑把关键词 searchText 赋值给 q 参数
String fetchUrl = String.format("https://cn.bing.com/images/async?q=%s&mmasync=1", searchText);
Document document;
try {
// 5. 提供 Jsoup 获取 url 参数对应的页面文档
document = Jsoup.connect(fetchUrl).get();
// Jsoup.connect(fetchUrl) 根据 url 连接页面
// get() 获取该页面文档, 需要捕获异常
} catch (IOException e) {
// 6. 打印日志并修改抛出的异常
log.error("获取页面失败");
// 这里捕获的是 get 抛出的异常, 并不是我们的操作错误, 因此修改抛出的异常为我们自定义的业务异常
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取页面失败");
}
// 7. 解析内容
// 获取到的 document 是整个页面的 HTML 内容; 现在需要从中提取所有图片元素, 并获取它们的类名(class)和 ID, 以便准确定位对应的图片元素
// 8. 先获取外层的元素
Element div = document.getElementsByClass("dgControl").first();
// 9. 判断外层元素是否存在, 存在则继续获取内层的图片
if (ObjUtil.isEmpty(div)) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取元素失败");
}
// 10. 获取内层元素, 找外层元素 class = dgControl 中, 内层 class = img.ming 的元素
Elements imgElementList = div.select("img");
// 这些内层元素有多个, 返回的应该是一个数组
// 11. 遍历数组元素 imgElementList, 依次上传图片
int uploadCount = 0;
// 记录上传图片的数量, 因为有一些图片可能上传失败, 导致实际的抓取数与定义的抓取数不同
for (Element imgElement : imgElementList) {
// 12. 获取每个元素的 src 属性, 其实就是图片元素的 url
String fileUrl = imgElement.attr("src");
if (StrUtil.isBlank(fileUrl)) {
// 13. 某个元素无法获取, 因此打印错误的图片 url , 结束对该元素的遍历(无需执行后续上传操作)
log.info("当前链接为空, 已跳过: {}", fileUrl);
continue;
}
// 14. 处理图片地址, 防止转义和对象存储冲突的问题(去掉 url 中的参数, 也就是 url 中 ? 后面的部分)
int questionMarkIndex = fileUrl.indexOf("?");
// 获取 url 中 ? 的下标
if (questionMarkIndex > -1) {
// 截取 url 中 ? 前面的部分
fileUrl = fileUrl.substring(0, questionMarkIndex);
}
// 15. 构造上传图片方法的参数
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
// pictureUploadRequest.allset, 再保留对 fileUrl 属性的 set
pictureUploadRequest.setFileUrl(fileUrl);
// 18. 捕获上传图片操作可能抛出的异常
try{
// 16. 上传图片, 其中 loginUser 参数是作为该批量抓取方法的参数传进来的
PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
// 17. 打印日志: 上传图片返回的结果对象中, 内置的 id
log.info("图片上传成功, id = {}", pictureVO.getId());
uploadCount++;
}catch (Exception e){
log.error("图片上传失败", e);
continue;
// 使用 continue, uploadCount++ 的操作不会被执行
}
if(uploadCount >= count){
break;
}
}
return uploadCount;
}
上述代码中,我们添加了很多日志记录和异常处理逻辑,使得单张图片抓取或导入失败时任务还能够继续执行,最终返回创建成功的图片数。
💡 如果抓取的内容数量较多,可以适当地
Thread.sleep
阻塞等待一段时间,减少服务器被封禁的概率。
3、开发接口
在 Controller 中新增接口,注意限制仅管理员可用:
@PostMapping("/upload/batch")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Integer> uploadPictureByBatch(
@RequestBody PictureUploadByBatchRequest pictureUploadByBatchRequest,
HttpServletRequest request) {
ThrowUtils.throwIf(pictureUploadByBatchRequest == null, ErrorCode.PARAMS_ERROR);
User loginUser = userService.getLoginUser(request);
int uploadCount = pictureService.uploadPictureByBatch(pictureUploadByBatchRequest, loginUser);
return ResultUtils.success(uploadCount);
}
4、扩展功能 - 批量设置属性
之前我们导入系统的图片名称都是由对方的 URL 决定的,名称可能乱七八糟,而且不利于我们得知数据是在哪一批被导入的。
因此,我们可以让管理员在执行任务前指定 名称前缀
,即导入到系统中的图片名称。
比如前缀为“鱼皮”,得到的图片名称就是“鱼皮1”、“鱼皮2”…… 相当于支持抓取和创建图片时批量对某批图片命名,名称前缀默认等于搜索关键词。
下面来开发实现:
(1)给 PictureUploadByBatchRequest
请求包装类补充 namePrefix
参数:
/**
* 名称前缀
*/
private String namePrefix;
(2)由于图片名称是在 uploadPicture
方法中传入并设置给 Picture
图片对象的,所以需要给该方法接受的参数 PictureUploadRequest
类中补充 picName
参数:
/**
* 图片名称
*/
private String picName;
(3)修改 uploadPicture
服务方法,在构造入库图片信息时,可以通过 pictureUploadRequest
对象获取到要手动设置的图片名称,而不是完全依赖于解析的结果:
// 构造要入库的图片信息
Picture picture = new Picture();
picture.setUrl(uploadPictureResult.getUrl());
String picName = uploadPictureResult.getPicName();
if (pictureUploadRequest != null && StrUtil.isNotBlank(pictureUploadRequest.getPicName())) {
picName = pictureUploadRequest.getPicName();
}
picture.setName(picName);
(4)修改批量抓取和导入图片的服务方法 uploadPictureByBatch
,补充图片名称生成逻辑:
String namePrefix = pictureUploadByBatchRequest.getNamePrefix();
if (StrUtil.isBlank(namePrefix)) {
namePrefix = searchText;
}
// ...
// 上传图片
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
if (StrUtil.isNotBlank(namePrefix)) {
// 设置图片名称,序号连续递增
pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));
}
5、接口测试
在给实体类添加属性后,要检查对应的 XML 映射是否被覆盖,如果被覆盖,要还原(可以考虑用 git 回滚,先看更新内容,也可以手动复制实体类路径再修改 XML)
可以通过 Swagger 测试批量抓取和创建图片功能,效果如图:
返回结果:
检查数据库:
但是我们发现,调用批量抓取接口抓取图片,然后上传到对象存储时,图片是没有后缀名的,这种情况该如何解决呢?我们后续再对这个问题进行解决
没有后缀其实不影响正常显示,想要添加后缀可以参考 https://www.codefather.cn/qa/1899730739983351809
扩展
- 支持管理员填写每批抓取图片的偏移量,防止重复抓取。
- 系统内部记录原始图片 URL,便于内部复盘归档,但是注意不需要暴露给用户。
- 和批量设置名称一样,支持批量设置抓取到的图片的分类和标签等。
- 我们目前抓取到的图片清晰度有限,可以尝试能否获取到质量更高的图片。
更多推荐
所有评论(0)