【智能协同云图库】第三期:实现用户上传图片及审核功能、使用模板方法模式优化上传图片功能、使用 Jsoup 实现批量抓取和上传图片功能
前言:在搭建好图库系统的基础传图能力后,我们将进一步拓展其核心功能边界。正如盖楼既需稳固地基,也需丰富楼层功能,本节聚焦的用户上传审核机制、URL 导入能力及批量抓取功能,希望这些实践能为你的开发之路提供切实帮助😘
摘要
本文围绕图片库的用户传图能力开发展开,涵盖用户上传图片及审核、通过 URL 导入图片、批量抓取和创建图片三大核心功能,详细阐述了各功能的需求分析、方案设计、后端开发及优化过程,同时涉及权限控制、数据校验、设计模式应用等关键要点。
文章超详细思维导图
本节重点
本节我们就重点开发用户传图能力,并支持更多传图的方式:
-
支持用户传图和审核功能
-
通过 URL 导入所需图片
-
Jsoup批量抓取网址图片
一、用户上传图片及审核
需求分析
对于用户上传图片及管理员审核图片的需求,重新总结如下:
- 用户上传创建图片:开放用户上传权限,流程和功能与管理员上传一致,同时需增加文件校验。
- 管理员审核图片:管理员可查看、筛选待审核图片,能标记通过或拒绝并填写具体原因,且要记录审核人、审核时间作为日志,以便在误审时追责。
方案设计
方案设计阶段我们需要确认:
-
审核的具体逻辑
-
库表设计
1、审核逻辑
-
审核状态流转:
- 图片默认状态为 “待审核”,可由管理员设置为 “审核通过” 或 “审核拒绝”
- 已拒绝的图片可重新审核为通过状态
- 已通过的图片可被撤销为拒绝状态
-
管理员自动审核机制:
- 管理员上传或更新的图片自动通过审核
- 系统自动填充审核参数:审核人为创建人、审核时间为当前时间、审核原因为 “管理员自动过审”
-
用户操作与审核规则:
- 用户上传或编辑图片后,状态自动重置为 “待审核”
- 重复审核时,支持两种模式:重置所有审核参数,或仅重置审核状态
- 历史审核信息后端保留(供管理员参考),前端不展示
-
内容可见性控制:
- 用户仅能查看 “审核通过” 状态的图片
- 管理员可查看所有图片,并能按审核状态筛选内容
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);
注意事项:
-
审核状态表示:
- 使用整数(0、1、2)表示审核状态(替代字符串),以节约表空间并提升查找效率
- 状态字段命名为 reviewStatus
-
索引设计:
- 为 reviewStatus 字段添加索引,优化按审核状态筛选图片的查询性能
后端开发
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; PictureReviewStatusEnum(String text, int value) { this.text = text; this.value = value; } /** * 根据 value 获取枚举 */ public static PictureReviewStatusEnum getEnumByValue(Integer value) { if (ObjUtil.isEmpty(value)) { return null; } for (PictureReviewStatusEnum pictureReviewStatusEnum : PictureReviewStatusEnum.values()) { 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)开发审核服务
接口:
/** * 图片审核 * * @param pictureReviewRequest * @param loginUser */
void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser);
实现类:
@Override
public void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) { Long id = pictureReviewRequest.getId(); Integer reviewStatus = pictureReviewRequest.getReviewStatus(); PictureReviewStatusEnum reviewStatusEnum = PictureReviewStatusEnum.getEnumByValue(reviewStatus); if (id == null || reviewStatusEnum == null || PictureReviewStatusEnum.REVIEWING.equals(reviewStatusEnum)) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } // 判断是否存在 Picture oldPicture = this.getById(id); ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR); // 已是该状态 if (oldPicture.getReviewStatus().equals(reviewStatus)) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "请勿重复审核"); } // 更新审核状态 Picture updatePicture = new Picture(); BeanUtils.copyProperties(pictureReviewRequest, updatePicture); updatePicture.setReviewerId(loginUser.getId()); updatePicture.setReviewTime(new Date()); boolean result = this.updateById(updatePicture); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}
3)开发审核接口,注意权限设置为仅管理员可用:
@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)的权限校验注解,但是注意,由于图片上传功能是支持图片编辑的,所以需要做好编辑权限控制 —— 仅本人或管理员可编辑。
修改 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); }
}
2)设置审核状态:管理员自动过审并且填充审核参数;用户上传或编辑图片时,图片的状态会被重置为“待审核”。
由于图片上传、用户编辑、管理员更新这 3 个操作都需要设置审核状态,所以我们可以先编写一个通用的 “补充审核参数” 的方法,根据用户的角色给图片对象填充审核字段的值。
@Override
public void fillReviewParams(Picture picture, User loginUser) { if (userService.isAdmin(loginUser)) { // 管理员自动过审 picture.setReviewStatus(PictureReviewStatusEnum.PASS.getValue()); picture.setReviewerId(loginUser.getId()); picture.setReviewMessage("管理员自动过审"); picture.setReviewTime(new Date()); } else { // 非管理员,创建或编辑都要改为待审核 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);
这样一来,后端就同时支持了 “管理员筛选审核状态” 的功能。
扩展
1、更多审核策略
在实际企业中,为了提高审核效率、减少垃圾内容,同时保证用户体验和平台的安全性,常常会结合技术手段和业务策略来优化审核流程。比如下面几点,大家可以按需扩展:
-
内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。
-
AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。
-
分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。
-
实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。
-
举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。
2、审核通知
当管理员完成审核后,系统可以通过消息中心或邮件通知用户审核结果。
二、通过 URL 导入图片
功能目标:
除本地文件上传外,支持通过输入远程 URL 直接导入网上图片,提高上传效率
实现方案及注意事项:
- 下载图片:后端使用 Hutool 的 HttpUtil.downloadFile 方法,从远程 URL 下载图片到本地临时存储
- 图片校验优化:
- 先校验 URL 字符串本身的合法性
- 通过 HEAD 请求获取文件元信息进行校验(不下载完整文件,节省资源)
- 避免使用 GET 请求(会获取完整文件,增加流量消耗)
- 上传图片:校验通过后,将图片上传到对象存储服务并生成存储 URL
- 后续流程:复用本地上传图片的现有流程
后端开发
1、服务开发
在 FileManager 类中编写通过 URL 上传文件的方法,该方法与之前的 uploadPicture 方法大部分代码一致,仅需改动 4 处:
- 方法参数:由原来的 MultipartFile 文件类型改为 String 字符串类型
- 校验部分:由校验文件改为校验 URL
- 文件名称获取:由从文件获取改为从 URL 获取
- 临时文件保存:由将 MultipartFile 写入临时文件改为从 URL 下载文件
代码如下:
/*** 通过url上传图片** @param fileUrl 文件url* @param uploadPathPrefix 上传路径前缀* @return*/public UploadPictureResult uploadPictureByUrl(String fileUrl, String uploadPathPrefix) {validPicture(fileUrl);//图片上传地址String uuid = RandomUtil.randomString(16);String originFilename = FileUtil.mainName(fileUrl);//getSuffix:获取文件后缀名的工具方法,返回文件名中最后一个点(`.`)之后的部分。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);HttpUtil.downloadFile(fileUrl, 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);}}
2、校验 URL 图片
编写校验 URL 图片的方法,分别校验 URL 格式、协议、文件是否存在、文件格式、文件大小。
代码如下:
private void validPicture(String fileUrl) { ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址不能为空"); try { // 1. 验证 URL 格式 new URL(fileUrl); // 验证是否是合法的 URL } catch (MalformedURLException e) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件地址格式不正确"); } // 2. 校验 URL 协议 ThrowUtils.throwIf(!(fileUrl.startsWith("http://") || fileUrl.startsWith("https://")), ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址"); // 3. 发送 HEAD 请求以验证文件是否存在 HttpResponse response = null; try { response = HttpUtil.createRequest(Method.HEAD, fileUrl).execute(); // 未正常返回,无需执行其他判断 if (response.getStatus() != HttpStatus.HTTP_OK) { return; } // 4. 校验文件类型 String contentType = response.header("Content-Type"); 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, "文件类型错误"); } // 5. 校验文件大小 String contentLengthStr = response.header("Content-Length"); if (StrUtil.isNotBlank(contentLengthStr)) { try { long contentLength = Long.parseLong(contentLengthStr); final long TWO_MB = 2 * 1024 * 1024L; // 限制文件大小为 2MB ThrowUtils.throwIf(contentLength > TWO_MB, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2M"); } catch (NumberFormatException e) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式错误"); } } } finally { if (response != null) { response.close(); } }
}
上述代码中,注意 2 点:
-
注意发送 HTTP 请求后,需要即时释放资源
-
有些 URL 地址可能不支持通过 HEAD 请求访问,为了提高导入成功率,即使 HEAD 请求访问失败,也不会报错,并且不用执行后续的校验。仅对能获取到的信息进行校验。
3、优化代码 - 模板方法模式
核心:父类定义图片上传的整体流程框架,子类通过重写抽象方法实现具体细节
目前我们的 FileManager 文件内写了两种不同的上传文件的方法,但是我们会发现,这两种方法的 流程完全一致、而且大多数代码都是相同的。
这种情况下,我们就要想要运用设计模式 —— 模板方法模式 对代码进行优化。
模板方法模式是行为型设计模式,适用于具有通用处理流程、但处理细节不同的情况。通过定义一个抽象模板类,提供通用的业务流程处理逻辑,并将不同部分定义为抽象方法,由子类具体实现。
在我们的场景中,两种文件上传方法的流程都是:
-
校验文件
-
获取上传地址
-
获取本地临时文件
-
上传到对象存储
-
封装解析得到的图片信息
-
清理临时文件
可以将这些流程抽象为一套抽象模板,将每个实现不一样的步骤都定义为一个抽象方法,比如:
-
校验图片
-
获取文件名称
-
保存临时文件
先在 manager
包下新建 upload
包,将模板方法有关的代码全部放在该包下统一管理。
1)新建图片上传模板 抽象类 PictureUploadTemplate,代码如下:
@Slf4j
public abstract class PictureUploadTemplate { @Resource protected CosManager cosManager; @Resource protected CosClientConfig cosClientConfig; /** * 模板方法,定义上传流程 */ public final UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix) { // 1. 校验图片 validPicture(inputSource); // 2. 图片上传地址 String uuid = RandomUtil.randomString(16); String originFilename = getOriginFilename(inputSource); 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 { // 3. 创建临时文件 file = File.createTempFile(uploadPath, null); // 处理文件来源(本地或 URL) processFile(inputSource, file); // 4. 上传图片到对象存储 PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file); ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo(); // 5. 封装返回结果 return buildResult(originFilename, file, uploadPath, imageInfo); } catch (Exception e) { log.error("图片上传到对象存储失败", e); throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败"); } finally { // 6. 清理临时文件 deleteTempFile(file); } } /** * 校验输入源(本地文件或 URL) */ protected abstract void validPicture(Object inputSource); /** * 获取输入源的原始文件名 */ protected abstract String getOriginFilename(Object inputSource); /** * 处理输入源并生成本地临时文件 */ protected abstract void processFile(Object inputSource, File file) throws Exception; /** * 封装返回结果 */ private UploadPictureResult buildResult(String originFilename, File file, String uploadPath, ImageInfo imageInfo) { 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; } /** * 删除临时文件 */ public void deleteTempFile(File file) { if (file == null) { return; } boolean deleteResult = file.delete(); if (!deleteResult) { log.error("file delete error, filepath = {}", file.getAbsolutePath()); } }
}
上述代码中,我们把每个步骤都封装为了一个单独的方法,公共的实现(比如 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, "文件地址不能为空"); try {// 1. 验证 URL 格式new URL(fileUrl); // 验证是否是合法的 URL} catch (MalformedURLException e) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件地址格式不正确");}// 2. 校验 URL 协议ThrowUtils.throwIf(!(fileUrl.startsWith("http://") || fileUrl.startsWith("https://")),ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址");// 3. 发送 HEAD 请求以验证文件是否存在HttpResponse response = null;try {response = HttpUtil.createRequest(Method.HEAD, fileUrl).execute();// 未正常返回,无需执行其他判断if (response.getStatus() != HttpStatus.HTTP_OK) {return;}// 4. 校验文件类型String contentType = response.header("Content-Type");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, "文件类型错误");}// 5. 校验文件大小String contentLengthStr = response.header("Content-Length");if (StrUtil.isNotBlank(contentLengthStr)) {try {long contentLength = Long.parseLong(contentLengthStr);final long ONE_MB =1024 * 1024L; // 限制文件大小为 2MBThrowUtils.throwIf(contentLength > ONE_MB, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2M");} catch (NumberFormatException e) {throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式错误");}}} finally {if (response != null) {response.close();}}} @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 的实际类型,来选择对应的图片上传子类。代码如下:
@Resource
private FilePictureUpload filePictureUpload; @Resource
private UrlPictureUpload urlPictureUpload; // 上传图片
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) { if (inputSource == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "图片为空"); } // ... // 按照用户 id 划分目录 String uploadPathPrefix = String.format("public/%s", loginUser.getId()); // 根据 inputSource 类型区分上传方式 PictureUploadTemplate pictureUploadTemplate = filePictureUpload; if (inputSource instanceof String) { pictureUploadTemplate = urlPictureUpload; } UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix); // 构造要入库的图片信息 // ...
}
💡 除了通过对象类型判断外,也可以通过传一个业务参数(如 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)在 PictureContoller 中新增接口,通过 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);
}
批量抓取和创建图片
需求分析
为了帮助管理员快速丰富图片库,冷启动项目,需要提供批量从网络抓取并创建图片的功能。
但是要注意,不建议将该功能开放给普通用户!主要是为了防止滥用导致的版权问题、低质量内容的上传、服务器资源消耗和安全问题。因为我们要从网络批量抓取图片(爬虫),如果功能开放给用户,相当于所有用户都在使用我们的服务器作为爬虫源头,容易导致我们的服务器 IP 被封禁。
方案设计
方案设计的重点包括:
-
如何抓取图片
-
抓取和导入规则
1、如何抓取图片?
绝大多数的图片素材网站,都是有版权保护的,不建议大家操作,容易被封禁 IP 和账号。比较安全的方法是从搜索引擎中抓取图片,仅学习使用、不商用的话基本不会有什么风险。
首先进入 bing 图片网站,从 Bing 搜索获取图片
获取图片的方式及关键要点总结:
-
获取方式选择:因直接抓取首页可能无法获取图片,故采用接口获取策略。通过 F12 观察到滚动加载图片时的接口为
https://cn.bing.com/images/async?q=%s&mmasync=1
(需带mmasync=1
参数,否则加载条数异常)。 -
解析工具与方法:接口返回 HTML 结构,推荐用 Java 的 jsoup 库解析。通过类选择器定位外层元素
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 int uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) { String searchText = pictureUploadByBatchRequest.getSearchText(); // 格式化数量 Integer count = pictureUploadByBatchRequest.getCount(); ThrowUtils.throwIf(count > 30, ErrorCode.PARAMS_ERROR, "最多 30 条"); // 要抓取的地址 String fetchUrl = String.format("https://cn.bing.com/images/async?q=%s&mmasync=1", searchText); Document document; try { document = Jsoup.connect(fetchUrl).get(); } catch (IOException e) { log.error("获取页面失败", e); throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取页面失败"); } Element div = document.getElementsByClass("dgControl").first(); if (ObjUtil.isNull(div)) { throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取元素失败"); } Elements imgElementList = div.select("img.mimg"); int uploadCount = 0; for (Element imgElement : imgElementList) { String fileUrl = imgElement.attr("src"); if (StrUtil.isBlank(fileUrl)) { log.info("当前链接为空,已跳过: {}", fileUrl); continue; } // 处理图片上传地址,防止出现转义问题 int questionMarkIndex = fileUrl.indexOf("?"); if (questionMarkIndex > -1) { fileUrl = fileUrl.substring(0, questionMarkIndex); } // 上传图片 PictureUploadRequest pictureUploadRequest = new PictureUploadRequest(); try { PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser); log.info("图片上传成功, id = {}", pictureVO.getId()); uploadCount++; } catch (Exception e) { log.error("图片上传失败", e); continue; } 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 决定的,名称可能乱七八糟,而且不利于我们得知数据是在那一批被导入的。
因此我们可以让管理员在执行任务前指定 名称前缀
,即导入到系统中的图片名称。比如前缀为 “鱼皮”,得到的图片名称就是 “ikun1”、“ikun2”。。。
相当于支持抓取和创建图片时批量对某批图片命名,名称前缀默认等于搜索关键词。
下面来开发实现:
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、接口测试
至此,相关的后端接口开发完毕,大功告成!🎉🎉🎉