【智能协同云图库】第三期:实现用户上传图片及审核功能、使用模板方法模式优化上传图片功能、使用 Jsoup 实现批量抓取和上传图片功能

发布于:2025-07-29 ⋅ 阅读:(12) ⋅ 点赞:(0)

前言:在搭建好图库系统的基础传图能力后,我们将进一步拓展其核心功能边界。正如盖楼既需稳固地基,也需丰富楼层功能,本节聚焦的用户上传审核机制URL 导入能力及批量抓取功能,希望这些实践能为你的开发之路提供切实帮助😘

摘要

本文围绕图片库的用户传图能力开发展开,涵盖用户上传图片及审核、通过 URL 导入图片、批量抓取和创建图片三大核心功能,详细阐述了各功能的需求分析、方案设计、后端开发及优化过程,同时涉及权限控制、数据校验、设计模式应用等关键要点。

文章超详细思维导图

本节重点

本节我们就؜重点开发用户传图能⁠力,并支持更多传图‏的方式:

  • 支持用户传图和审核功能

  • 通过 URL 导入所需图片

  • Jsoup批量抓取网址图片

一、用户上传图片及审核

需求分析

对于用户上传图片及管理员审核图片的需求,重新总结如下:

  1. 用户上传创建图片:开放用户上传权限,流程和功能与管理员上传一致,同时需增加文件校验。
  2. 管理员审核图片:管理员可查看、筛选待审核图片,能标记通过或拒绝并填写具体原因,且要记录审核人、审核时间作为日志,以便在误审时追责。

方案设计

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

  • 审核的具体逻辑

  • 库表设计


1、审核逻辑
  1. 审核状态流转:

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

    • 管理员上传或更新的图片自动通过审核
    • 系统自动填充审核参数:审核人为创建人、审核时间为当前时间、审核原因为 “管理员自动过审”
  3. 用户操作与审核规则:

    • 用户上传或编辑图片后,状态自动重置为 “待审核”
    • 重复审核时,支持两种模式:重置所有审核参数,或仅重置审核状态
    • 历史审核信息后端保留(供管理员参考),前端不展示
  4. 内容可见性控制:

    • 用户仅能查看 “审核通过” 状态的图片
    • 管理员可查看所有图片,并能按审核状态筛选内容

2、库表设计

为了支持审؜核功能,我们在 p⁠icture 图片‏表中新增审核相关字‌段,同时优化索引设‏计提升性能。

修改表的 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. 审核状态表示:

    • 使用整数(0、1、2)表示审核状态(替代字符串),以节约表空间并提升查找效率
    • 状态字段命名为 reviewStatus
  2. 索引设计:

    • 为 reviewStatus 字段添加索引,优化按审核状态筛选图片的查询性能

后端开发

1、数据模型开发

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

1)实体类 Picture 新增:

/**  
 * 状态:0-待审核; 1-通过; 2-拒绝  
 */  
private Integer reviewStatus;  
  
/**  
 * 审核信息  
 */  
private String reviewMessage;  
  
/**  
 * 审核人 id  
 */  
private Long reviewerId;  
  
/**  
 * 审核时间  
 */  
private Date reviewTime;

2)图片查؜询请求类 Pict⁠ureQueryR‏equest 新增‌:

/**  
 * 状态: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)权限控制

首先取消上传图片؜接口(uploadPictur⁠e)的权限校验注解,但是注意,‏由于图片上传功能是支持图片编辑‌的,所以需要做好编辑权限控制 ‏—— 仅本人或管理员可编辑。

修改 Pi؜ctureServ⁠ice 的 upl‏oadPictur‌e 方法,补充权限‏校验逻辑:

// 如果是更新图片,需要校验图片是否存在  
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、控制内容可见性

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

// 普通用户默认只能查看已过审的数据  
pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue());  
// 查询数据库  
Page<Picture> picturePage = pictureService.page(new Page<>(current, size),  
        pictureService.getQueryWrapper(pictureQueryRequest));

需要同步更؜改 PictureS⁠ervice 的 g‏etQueryWra‌pper 方法,支持‏根据审核字段进行查询:

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、更多审核策略

在实际企业中,؜为了提高审核效率、减少垃圾内⁠容,同时保证用户体验和平台的‏安全性,常常会结合技术手段和‌业务策略来优化审核流程。比如‏下面几点,大家可以按需扩展:

  1. 内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。

  2. AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。

  3. 分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。

  4. 实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。

  5. 举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。

2、审核通知

当管理员完成؜审核后,系统可以通过消⁠息中心或邮件通知用户审核‏结果。

二、通过 URL 导入图片

功能目标:
除本地文件上传外,支持通过输入远程 URL 直接导入网上图片,提高上传效率

实现方案及注意事项:

  • 下载图片:后端使用 Hutool 的 HttpUtil.downloadFile 方法,从远程 URL 下载图片到本地临时存储
  • 图片校验优化:
    • 先校验 URL 字符串本身的合法性
    • 通过 HEAD 请求获取文件元信息进行校验(不下载完整文件,节省资源)
    • 避免使用 GET 请求(会获取完整文件,增加流量消耗)
  • 上传图片:校验通过后,将图片上传到对象存储服务并生成存储 URL
  • 后续流程:复用本地上传图片的现有流程

    后端开发

    1、服务开发

    在 FileManager 类中编写通过 URL 上传文件的方法,该方法与之前的 uploadPicture 方法大部分代码一致,仅需改动 4 处:

    1. 方法参数:由原来的 MultipartFile 文件类型改为 String 字符串类型
    2. 校验部分:由校验文件改为校验 URL
    3. 文件名称获取:由从文件获取改为从 URL 获取
    4. 临时文件保存:由将 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 点:

    1. 注意发送 HTTP 请求后,需要即时释放资源

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

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

    核心:父类定义图片上传的整体流程框架,子类通过重写抽象方法实现具体细节

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

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

    模板方法模式是行؜为型设计模式,适用于具有通用处理⁠流程、但处理细节不同的情况。通过‏定义一个抽象模板类,提供通用的业‌务流程处理逻辑,并将不同部分定‏义为抽象方法,由子类具体实现。

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

    1. 校验文件

    2. 获取上传地址

    3. 获取本地临时文件

    4. 上传到对象存储

    5. 封装解析得到的图片信息

    6. 清理临时文件

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

    1. 校验图片

    2. 获取文件名称

    3. 保存临时文件

    先在 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; // 限制文件大小为 2MB
                        ThrowUtils.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 上传

    由于图片上؜传的逻辑还是比较复⁠杂的,尽量让 UR‏L 上传复用之前‌的代码。

    但是之前图؜片上传服务的 upl⁠oadPicture‏ 方法接受的是文件类‌型的参数,现在要支持‏ URL 上传,怎么办呢?

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

    @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);  
        // 构造要入库的图片信息  
        // ...  
    }
    

    💡 除了؜通过对象类型判断外⁠,也可以通过传一个‏业务参数(如 ty‌pe)来区分不同的‏上传方式。

    5、接口开发

    1)在请求؜封装类 Pictu⁠reUploadR‏equest 中新‌增 fileUrl‏ 文件地址:

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

    2)在 P؜ictureCon⁠toller 中新‏增接口,通过 UR‌L 上传图片:

    /**  
     * 通过 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 搜索获取图片

    获取图片的方式及关键要点总结:

    1. 获取方式选择:因直接抓取首页可能无法获取图片,故采用接口获取策略。通过 F12 观察到滚动加载图片时的接口为https://cn.bing.com/images/async?q=%s&mmasync=1(需带mmasync=1参数,否则加载条数异常)。

    2. 解析工具与方法:接口返回 HTML 结构,推荐用 Java 的 jsoup 库解析。通过类选择器定位外层元素dgControl,再用 CSS 选择器img.mimg提取图片元素。

    3. 注意事项:图片地址后的附加参数(如?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、开发接口

    在 Con؜troller 中⁠新增接口,注意限制‏仅管理员可用:

    @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)给 P؜ictureUpl⁠oadByBatc‏hRequest ‌请求包装类补充 n‏amePrefix 参数:

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

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

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

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

    // 构造要入库的图片信息  
    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)修改批؜量抓取和导入图片的⁠服务方法 uplo‏adPicture‌ByBatch,补‏充图片名称生成逻辑:

    String namePrefix = pictureUploadByBatchRequest.getNamePrefix();  
    if (StrUtil.isBlank(namePrefix)) {  
        namePrefix = searchText;  
    }  
    // ...  
    // 上传图片  
    PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();  
    if (StrUtil.isNotBlank(namePrefix)) {  
        // 设置图片名称,序号连续递增  
        pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));  
    }
    

    5、接口测试


    至此,相关的后端接口开发⁠完毕,大功告成!🎉🎉🎉


    网站公告

    今日签到

    点亮在社区的每一天
    去签到