在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


本节重点


之前为了方便和安全性,只支持管理员上传图片。如果想让平台素材更丰富,也要允许用户自主上传图片。本节我们就重点开发用户传图能力,并支持更多传图的方式。

大纲:

  1. 支持用户上传图片和审核功能
  2. 通过 URL 导入图片
  3. 批量抓取和创建图片

一、用户上传图片及审核


需求分析


之前我们已经开发了管理员上传图片功能,想实现用户上传图片就比较简单了,但是我们要考虑到一点:“用户上传的内容可能是不安全的”。

一般只要涉及到“用户上传内容”(俗称 UGC)的场景,就要增加审核功能


具体分析每个需求:

  1. 用户上传创建图片:

    • 需要开放权限,允许用户上传图片。
    • 功能和流程,跟之前管理员上传图片一致。
    • 也要增加文件校验。
  2. 管理员审核图片:

    • 管理员可以查看和筛选,所有待审核的图片。
    • 并标记为通过或拒绝,可填写通过或拒绝的具体原因。
    • 此外,需要记录审核人和审核时间作为日志,如果发现误审的情况也可以追责

方案设计


1. 审核逻辑


  1. 管理员可以操作审核的状态流转

    • 默认为“待审核”,可以设置为“审核通过”或“审核拒绝”。
    • 已拒绝的图片,可以重新审核为通过。
    • 已通过的图片,可以撤销为拒绝状态。
  2. 管理员自动审核:

    • 管理员上传/更新图片时,图片自动审核通过,并且自动填充审核参数——设置审核人为创建人、审核时间为当前时间、审核原因为“管理员自动过审”。
  3. 用户操作需要审核:

    • 用户上传或编辑图片时,图片的状态会被重置为“待审核”
    • 重复审核时,既可以选择重置所有审核参数,也可以仅重置审核状态
    • 其余参数在前端不展示,但是在后端保留,以便管理员参考历史审核信息
  4. 控制内容可见性:

    • 对于用户来说,应该只能看见“审核通过”状态的数据
    • 管理员可以在图片管理页面看到所有数据,并且根据审核状态筛选图片

image-20250701150844978


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);

注意事项:

  1. 审核状态:reviewStatus 使用整数(0、1、2)表示不同的审核状态,而不是用字符串,可以节约表的空间、提升查找效率。
  2. 索引设计:由于要根据审核状态筛选图片,所以给该字段添加索引,提升查询性能。

后端开发


1. 数据模型开发


由于新增了一些审核相关的字段,要对原有的数据模型(实体类、包装类等)进行修改。


(1)实体类 Picture 新增

image-20250701163200559

/**
 * 状态:0-待审核; 1-通过; 2-拒绝
 */
private Integer reviewStatus;

/**
 * 审核信息
 */
private String reviewMessage;

/**
 * 审核人 id
 */
private Long reviewerId;

/**
 * 审核时间
 */
private Date reviewTime;

image-20250701164749348


(2)图片查询请求类 PictureQueryRequest 新增:

image-20250701164906454

/**
 * 状态:0-待审核; 1-通过; 2-拒绝
 */
private Integer reviewStatus;

/**
 * 审核信息
 */
private String reviewMessage;

/**
 * 审核人 id
 */
private Long reviewerId;

(3)新建审核状态枚举类

image-20250701165117010

@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)开发请求包装类

开发请求包装类,注意不需要增加 reviewerIdreviewTime 字段,这两个是由系统自动填充的,而不是由前端传递。

image-20250701170202913

@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)开发审核服务接口

image-20250701170331337

对于该接口,执行的结果为审核成功和审核失败,审核失败会直接抛异常,因此接口的返回值类型设置为 void:

/**
 * 图片审核
 *
 * @param pictureReviewRequest  审核请求类
 * @param loginUser  当前登录用户
 */
void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser);

实现类:

image-20250701170355451

@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)开发审核接口

开发审核接口,注意权限设置为仅管理员可用:

image-20250701174205198

/**
 * 图片审核功能
 * @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)的权限校验注解

image-20250701174205198

/**
 * 上传图片(可重新上传)
 */
@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);
}

但是注意,由于图片上传功能是支持图片编辑的,所以需要做好编辑权限控制 —— 仅本人或管理员可编辑。

修改 PictureServiceuploadPicture 方法,补充权限校验逻辑:

image-20250701175817050

// 如果是更新图片,需要校验图片是否存在
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个操作都需要设置审核状态,所以我们可以先编写一个通用的“补充审核参数”的方法

image-20250701183834839

根据用户的角色,给图片对象填充审核字段的值。

/**
 * 填充审核参数接口
 * @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个操作补充审核参数(在接口执行数据库操作前,补充审核参数):


为图片更新接口补充审核参数

image-20250701183921416

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);
}

image-20250701184550131


为图片修改接口补充审核参数

image-20250701183926553

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);
}

image-20250701184222647


为上传图片服务补充审核参数

image-20250701183834839

@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());
    }
    // ...
}

image-20250701183743085


4. 控制内容可见性


目前我们只有主页给用户查看图片列表,所以需要修改主页调用的 listPictureVOByPage 接口,补充查询条件即可,默认只能查看已过审的数据:

image-20250703154607274

关键修改位置的代码:

// 普通用户默认只能查看已过审的数据
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());

// 查询数据库
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),
    pictureService.getQueryWrapper(pictureQueryRequest));

需要同步更改 PictureServicegetQueryWrapper 方法,支持根据审核字段进行查询:

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 本身进行校验。

  1. 首先是校验 URL 字符串本身的合法性,比如要是一个合理的 URL 地址。
  2. 此外,可以先使用 HEAD 请求来获取 URL 对应文件的元信息(如文件大小、格式等)。
  3. HEAD 请求仅返回 HTTP 响应头信息,而不会下载文件的内容,大大降低了网络流量的消耗
  4. 注意此处不能使用 GET 请求,它会获取完整文件

(3)上传图片

  1. 将校验通过的图片上传到对象存储服务,生成存储 URL。
  2. 之后的流程就都可以复用从本地上传图片的流程了。

后端开发


1、服务开发


先编写通过 URL 上传文件的方法,为了便于开发,直接在 FileManager 类中编写

image-20250703164441522

绝大多数代码跟之前的 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
    }
}

image-20250710152159478

image-20250710154927934


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 点:

  1. 注意发送 HTTP 请求后,需要即时释放资源
  2. 有些 URL 地址可能不支持通过 HEAD 请求访问,为了提高导入成功率,即使 HEAD 请求访问失败,也不会报错,并且不用执行后续的校验。仅对能获取到的信息进行校验。

3、优化代码 - 模板方法模式


目前我们的 FileManager 文件内写了两种不同的上传文件的方法,但是我们会发现,这两种方法的流程完全一致、而且大多数代码都是相同的。

image-20250703180507871

这种情况下,我们就要想要运用设计模式 —— 模板方法模式 对代码进行优化。

模板方法模式是行为型设计模式,适用于具有通用处理流程、但处理细节不同的情况。

通过定义一个抽象模板类,提供通用的业务流程处理逻辑,并将不同的部分定义为抽象方法,由子类具体实现

在我们的场景中,两种文件上传方法的流程都是:

  1. 校验文件
  2. 获取上传地址
  3. 获取本地临时文件
  4. 上传到对象存储
  5. 封装解析得到的图片信息
  6. 清理临时文件

可以将这些流程抽象为一套模板(抽象类),将每个实现不一样的步骤都定义为一个抽象方法,比如:

  • 校验图片
  • 获取文件名称
  • 保存临时文件

下面开始开发

先在 manager 包下新建 upload 包,将模板方法有关的代码全部放在该包下统一管理。

image-20250704150555062


(1)新建图片上传模板抽象类 PictureUploadTemplate

复制一份写好的文件上传类,对已有代码进行修改重构,修改类名,并修改类声明为抽象类:

image-20250704150806056


image-20250704155543066

代码如下:

@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)可以直接放到模板中,而不用放到具体的实现类中。

注意,为了让模板同时兼容 MultipartFileString 类型的文件参数,直接将这两种情况统一为 Object 类型的 inputSource 输入源。


(2)新建本地图片上传子类

FilePictureUpload,继承模板

image-20250704160952364

并且打上 @Service 注解生成 Bean 实例:

image-20250704161615003

@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 实例:

image-20250704161919218

@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

image-20250704162630737

/**
 * 文件服务
 * @deprecated 已废弃,改为使用 upload 包的模板方法优化
 */
@Deprecated

4、图片上传服务支持 URL 上传


由于图片上传的逻辑还是比较复杂的,尽量让 URL 上传复用之前的代码。

image-20250704165724628


但是之前图片上传服务的 uploadPicture 方法接受的是文件类型的参数:

image-20250704165814552

现在要支持 URL 上传,怎么办呢?


可以将输入参数跟上述模板一样,改为 Object 类型的 inputSource,然后在代码中可以根据 inputSource 的实际类型,来选择对应的图片上传子类。


修改接口参数:

image-20250704170316233


修改实现类参数,并添加关于文件上传类、URL 上传类这两个类的 bean 对象

image-20250704170431855


修改实现类报错的位置:

image-20250704171041546

由于 fileManager 类已被废弃,现在改用 pictureUploadTemplate 抽象类对象来处理文件上传。根据输入源 inputSource 的类型,动态选择 pictureUploadTemplate 的具体实现类:

  1. 判断输入源类型:
    • 如果 inputSourceString 类型,则将 pictureUploadTemplate 设置为其子类 urlPictureUpload 的实例。
    • 如果 inputSourceMultipartFile 类型,则将 pictureUploadTemplate 设置为其子类 filePictureUpload 的实例。
  2. 调用上传方法:
    • 使用 pictureUploadTemplateuploadPicture 模板方法上传文件,并获取上传结果

代码如下:

@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 文件地址:

image-20250704172415764

@Data
public class PictureUploadRequest implements Serializable {
    /**
     * 图片 id(用于修改)
     */
    private Long id;

    /**
     * 文件地址
     */
    private String fileUrl;

    private static final long serialVersionUID = 1L;
}

2)在 PictureController 中新增接口,通过 URL 上传图片:

image-20250704172433578

/**
 * 通过 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

image-20250704174219384


三、批量抓取和创建图片


需求分析


为了帮助管理员快速丰富图片库,冷启动项目,需要提供批量从网络抓取并创建图片的功能。但是要注意,不建议将该功能开放给普通用户!

主要是为了防止滥用导致的版权问题、低质量内容的上传、服务器资源消耗和安全问题。

因为我们要从网络批量抓取图片(爬虫),如果功能开放给用户,相当于所有用户都在使用我们的服务器作为爬虫源头,容易导致我们的服务器 IP 被封禁。


方案设计


方案设计的重点包括:

  • 如何抓取图片
  • 抓取和导入规则

1、如何抓取图片?


思考 2 个问题:

  • 从哪里抓取图片?
  • 怎么抓取图片呢?

绝大多数的图片素材网站,都是有版权保护的,不建议大家操作,容易被封禁 IP 和账号。比较安全的方法是从搜索引擎中抓取图片,仅学习使用、不商用的话基本不会有什么风险。

这里我们选择从 Bing 搜索获取图片,首先进入 Bing 图片网站,可以看到很多图片,但是如何获取这些图片呢?

image-20250710100744667

有 2 种常见的做法:

  1. 请求到完整的页面内容后,对页面的 HTML 结构进行解析,提取到图片的地址,再通过 URL 下载。
  2. 直接调用后端获取图片地址的接口拿到图片数据。

要使用哪种方式,还是要具体情况具体分析。比如在调研过程中,我们会发现直接从 Bing 图片的首页抓取数据,可能会出现获取不到图片的情况。所以我们换一种策略,尝试去找图片接口。

按 F12 打开网络请求控制台,向下滚动图片时会触发新一波图片的加载,就能看到获取图片数据的接口了

image-20250710101911232

https://cn.bing.com/images/async?q=%s&mmasync=1

注意,URL 地址必须要添加 mmasync=1 参数!否则加载条数不对。


但是该接口返回的还是 HTML 文档结构,所以我们需要使用一个 HTML 文档解析库来提取图片地址,Java 中比较推荐 jsoup,非常轻量。

jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。我们可以先通过类选择器找到最外层的元素 dgControl,再通过 CSS 选择器 img.mimg 找到所有的图片元素:

image-20250710102239566


注意,图片的地址后面有很多附加参数,比如 ?w=199&h=180,在导入图片时一定要移除!

否则会影响图片的质量,还有可能导致上传到对象存储的文件包含被转义的特殊字符,引发无法访问等问题。

image-20250710103836260


2、抓取和导入规则


可以在抓取时,让管理员填写以下参数:

  • 搜索关键词:便于找到需要的数据
  • 抓取数量:单次要抓取的条数,不建议超过 30 条(接口单次返回的图片有限)

后端开发


1、定义请求体


model.dto.picture 包下新建 PictureUploadByBatchRequest

image-20250710105322011

@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)编写批量抓取和创建图片方法接口

image-20250710120139517

/**
 * 批量抓取和创建图片
 *
 * @param pictureUploadByBatchRequest  批量抓取图片请求类
 * @param loginUser  获取登录用户信息, 只有管理员才可以调用该接口
 * @return 成功创建的图片数
 */
Integer uploadPictureByBatch(
    PictureUploadByBatchRequest pictureUploadByBatchRequest,
    User loginUser);

实现类:

image-20250710120204187

@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 中新增接口,注意限制仅管理员可用:

image-20250710120300940

@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”…… 相当于支持抓取和创建图片时批量对某批图片命名,名称前缀默认等于搜索关键词。


下面来开发实现:

image-20250710124758367

(1)给 PictureUploadByBatchRequest 请求包装类补充 namePrefix 参数:

image-20250710121055335

/**
 * 名称前缀
 */
private String namePrefix;

(2)由于图片名称是在 uploadPicture 方法中传入并设置给 Picture 图片对象的,所以需要给该方法接受的参数 PictureUploadRequest 类中补充 picName 参数:

image-20250710124834764

/**
 * 图片名称
 */
private String picName;

(3)修改 uploadPicture 服务方法,在构造入库图片信息时,可以通过 pictureUploadRequest 对象获取到要手动设置的图片名称,而不是完全依赖于解析的结果:

image-20250710124859769

// 构造要入库的图片信息
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,补充图片名称生成逻辑:

image-20250710124905249

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)

image-20250710125538962


可以通过 Swagger 测试批量抓取和创建图片功能,效果如图:

image-20250710125743269


返回结果:

image-20250710143617360


检查数据库:

image-20250710143719677


但是我们发现,调用批量抓取接口抓取图片,然后上传到对象存储时,图片是没有后缀名的,这种情况该如何解决呢?我们后续再对这个问题进行解决

没有后缀其实不影响正常显示,想要添加后缀可以参考 https://www.codefather.cn/qa/1899730739983351809

image-20250710160042201


扩展


  1. 支持管理员填写每批抓取图片的偏移量,防止重复抓取。
  2. 系统内部记录原始图片 URL,便于内部复盘归档,但是注意不需要暴露给用户。
  3. 和批量设置名称一样,支持批量设置抓取到的图片的分类和标签等。
  4. 我们目前抓取到的图片清晰度有限,可以尝试能否获取到质量更高的图片。

在这里插入图片描述

在这里插入图片描述

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐