用户传图

本节重点

之前为了方便和安全性,只支持管理员上传图片,如果想让平台素材更丰富,也要允许用户自主上传图片。

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

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

一、用户上传图片及审核

需求分析

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

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

具体分析每个需求:

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

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

方案设计

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

  • 审核的具体逻辑
  • 库表设计

1、审核逻辑

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

  • 默认为 “待审核”,可以设置为 “审核通过” 或 “审核拒绝”
  • 已拒绝的图片可以重新审核为通过
  • 已通过的图片可以撤销为拒绝状态

2)管理员自动审核:管理员上传 / 更新图片时,图片自动审核通过,并且自动填充审核参数 —— 设置审核人为创建人、审核时间为当前时间、审核原因为 “管理员自动过审”。

3)用户操作需要审核:用户上传或编辑图片时,图片的状态会被重置为“待审核”。

重复审核时,既可以选择重置 所有 审核参数,也可以仅重置审核状态。其余参数在前端不展示,但是在后端保留,以便管理员参考历史审核信息。

4)控制内容可见性:对于用户来说,应该只能看见 “审核通过” 状态的数据;管理员可以在图片管理页面看到所有数据,并且根据审核状态筛选图片。

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 新增:

/**
 * 状态: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, LoginUserVO loginUser) {

        Picture oldPicture = this.getById(pictureReviewRequest.getId());
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        if (oldPicture.getReviewStatus().equals(pictureReviewRequest.getReviewStatus())) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "请勿重复审核");
        }

        // 更新审核状态
        Picture updatePicture = new Picture();
        BeanUtils.copyProperties(pictureReviewRequest, updatePicture);
        updatePicture.setReviewerId(loginUser.getId());
        updatePicture.setReviewTime(new Date());
        if (StrUtil.isNotBlank(pictureReviewRequest.getReviewMessage())) {
            updatePicture.setReviewMessage(pictureReviewRequest.getReviewMessage());
        }
        boolean result = this.updateById(updatePicture);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "审核失败");
    }

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

   @PostMapping("/review")
    @PreAuthorizeRole("ADMIN")
    public BaseResponse<Boolean> doPictureReview(@RequestBody @Valid PictureReviewRequest pictureReviewRequest,
                                                 HttpServletRequest request) {
        ThrowUtils.throwIf(pictureReviewRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");

        LoginUserVO 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) {
    // ...
    Picture picture = new Picture();
        BeanUtils.copyProperties(pictureEditRequest, picture);
        if (pictureEditRequest.getTags() != null) {
            picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));
        }
        picture.setEditTime(new Date());

        pictureService.validPicture(picture);

        pictureService.fillReviewParams(picture, loginUser);

        boolean result = pictureService.updateById(picture);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片编辑失败");

        return ResultUtils.success(true);
}

上传图片服务:

public PictureVO uploadPicture(MultipartFile multipartFile, PictureUploadRequest
            pictureUploadRequest, LoginUserVO loginUser) {
    // ...
  if (pictureId != null) {
            picture.setId(pictureId);
            picture.setEditTime(currentTime);

            // 删除旧图片
            Picture oldPicture = this.getById(pictureId);
            if (oldPicture != null && oldPicture.getUrl() != null) {
                fileManager.deletePicture(getFullUrl(oldPicture.getUrl()));
            }
        }

        this.fillReviewParams(picture, loginUser);

        boolean result = this.saveOrUpdate(picture);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败");

        // 重新查询数据库以确保数据一致性
        Picture newPicture = this.getById(picture.getId());
        return convertToPictureVO(newPicture);
}

4、控制内容可见性

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

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

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

Integer reviewStatus = pictureQueryRequest.getReviewStatus();
String reviewMessage = pictureQueryRequest.getReviewMessage();
Long reviewerId = pictureQueryRequest.getReviewerId();
queryWrapper.eq(ObjUtil.isNotEmpty(reviewStatus), "reviewStatus", reviewStatus);
queryWrapper.like(StrUtil.isNotBlank(reviewMessage), "reviewMessage", reviewMessage);
queryWrapper.eq(ObjUtil.isNotEmpty(reviewerId), "reviewerId", reviewerId);

这样一来,后端就同时支持了 “管理员筛选审核状态” 的功能。

至此,用户上传图片及审核的后端就开发完成了。

Q:根据 id 查询图片的接口需要做同样的限制么?

A:对目前咱们的系统来说,用户正常情况下不会得到未过审图片的 id,影响面较小,可以暂时不做,感兴趣的同学按需优化即可。

前端开发

控制首页未过审的图片不可见已经通过后端接口实现,我们只需开发管理员审核和按审核状态筛选图片功能即可。

1、定义审核常量

和后端一样,前端也有很多地方要用到审核状态信息,可以定义为一个常量。

constants 目录下新建 picture.ts 常量文件,定义枚举信息、对应的中文映射、以及后续筛选审核状态时要用到的选项数组:

export const PIC_REVIEW_STATUS_ENUM = {
  REVIEWING: 0,
  PASS: 1,
  REJECT: 2,
}

export const PIC_REVIEW_STATUS_MAP = {
  0: '待审核',
  1: '通过',
  2: '拒绝',
}

export const PIC_REVIEW_STATUS_OPTIONS = Object.keys(PIC_REVIEW_STATUS_MAP).map((key) => {
  return {
    label: PIC_REVIEW_STATUS_MAP[key],
    value: key,
  }
})

2、管理员审核功能

1)表格列新增审核信息:

const columns = [
  // ...
  {
    title: '审核信息',
    dataIndex: 'reviewMessage',
    align: 'center',
  },
  // ...
]

2)自定义审核信息列要展示的内容:

 <template v-if="column.dataIndex === 'reviewMessage'">
          <div>审核状态:{{ PIC_REVIEW_STATUS_MAP[record.reviewStatus] }}</div>
          <div v-if="record.reviewStatus !== 0">
            <div>审核信息:{{ record.reviewMessage }}</div>
            <div>审核人:{{ record.reviewerId }}</div>
            <div v-if="record.reviewTime">审核时间:{{ formatDateTime(record.reviewTime) }}</div>
          </div>
        </template>

3)新增审核通过和拒绝的操作按钮:

<template v-else-if="column.key === 'action'">
          <a-space>
            <a-button
              v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.PASS"
              type="link"
              @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.PASS)"
            >
              通过
            </a-button>
            <a-button
              v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.REJECT"
              type="link"
              danger
              @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.REJECT)"
            >
              拒绝
            </a-button>
            <a-button type="dashed" @click="editPicture(record.id)">编辑</a-button>
            <a-button danger @click="confirmDelete(record.id)">删除</a-button>
          </a-space>
        </template>

4)编写审核函数,调用后端完成操作:

const handleReview = async (record: API.Picture, reviewStatus: number) => {
  const reviewMessage = reviewStatus === PIC_REVIEW_STATUS_ENUM.PASS ? '管理员操作通过' : '管理员操作拒绝'
  const res = await doPictureReviewUsingPost({
    id: record.id,
    reviewStatus,
    reviewMessage,
  })
  if (res.data.code === 0) {
    message.success('审核操作成功')
    // 重新获取列表
    fetchData()
  } else {
    message.error('审核操作失败,' + res.data.message)
  }
}

3、按审核状态筛选

只需要在原来的搜索表单中补充一个表单项即可,使用下拉框组件,传入定义好的审核状态常量作为选项:

<a-form-item label="审核状态" name="reviewStatus">
  <a-select
    v-model:value="searchParams.reviewStatus"
    :options="PIC_REVIEW_STATUS_OPTIONS"
    placeholder="请输入审核状态"
    style="min-width: 180px"
    allow-clear
  />
</a-form-item>

前端扩展

1)审核通过或拒绝时可以填写原因,可以利用 模态框组件 实现

2)可以在详情页添加审核通过、审核拒绝的快捷操作按钮,仅管理员可见

3)删除和拒绝属于危险操作,可以点击后提示确认框,确认后才执行操作。可以利用 组件 实现

package com.zwnsyw.yunpicturebackend.controller;

import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.zwnsyw.yunpicturebackend.annotation.Anonymous;
import com.zwnsyw.yunpicturebackend.annotation.PreAuthorizeRole;
import com.zwnsyw.yunpicturebackend.common.BaseResponse;
import com.zwnsyw.yunpicturebackend.common.DeleteRequest;
import com.zwnsyw.yunpicturebackend.common.ResultUtils;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.ThrowUtils;
import com.zwnsyw.yunpicturebackend.manager.FileManager;
import com.zwnsyw.yunpicturebackend.model.dto.PictureRequest.*;
import com.zwnsyw.yunpicturebackend.model.entity.Picture;
import com.zwnsyw.yunpicturebackend.model.vo.LoginUserVO;
import com.zwnsyw.yunpicturebackend.model.vo.PictureCursorResponse;
import com.zwnsyw.yunpicturebackend.model.vo.PictureTagCategory;
import com.zwnsyw.yunpicturebackend.model.vo.PictureVO;
import com.zwnsyw.yunpicturebackend.service.PictureService;
import com.zwnsyw.yunpicturebackend.service.UserService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

import static com.zwnsyw.yunpicturebackend.contant.UserConstant.ADMIN_ROLE;

@RestController
@RequestMapping("picture")
@Validated
public class PictureController {

    @Autowired
    private UserService userService;

    @Autowired
    private PictureService pictureService;

    @Autowired
    private FileManager fileManager;

    /**
     * 上传图片(可重新上传)
     */
    @PostMapping("/upload")
    @PreAuthorizeRole("USER")
    public BaseResponse<PictureVO> uploadPicture(
            @RequestPart("file") MultipartFile multipartFile,
            PictureUploadRequest pictureUploadRequest,
            HttpServletRequest request) {
        LoginUserVO loginUser = userService.getLoginUser(request);
        PictureVO pictureVO = pictureService.uploadPicture(multipartFile, pictureUploadRequest, loginUser);
        return ResultUtils.success(pictureVO);
    }

    /**
     * 下载图片
     * 后端生成一个带有签名的URL,该URL允许前端在一定时间内直接从COS下载文件
     * 后端将生成的预签名URL返回给前端
     * 前端使用预签名URL直接从COS下载文件,无需经过后端服务器中转
     * todo 需要考虑是否还需要将真实url封装入vo返回给前端了 因为无需真实url来进行下载了 (其实无伤大雅 暂时留着 以后发现确实没有可以再移除)
     */
    @GetMapping("/{id}/download")
    @PreAuthorizeRole("USER")
    public BaseResponse<String> downloadPicture(
            @PathVariable Long id,
            HttpServletRequest request) {

        LoginUserVO loginUser = userService.getLoginUser(request);

        Picture picture = pictureService.getById(id);
        ThrowUtils.throwIf(picture == null,
                ErrorCode.NOT_FOUND_ERROR,
                "图片不存在");

//        // 用户只能下载自己上传的图片
//        Long userId = loginUser.getId();
//        ThrowUtils.throwIf(!userId.equals(picture.getUserId())
//                        && !loginUser.getRoles().contains(ADMIN_ROLE),
//                ErrorCode.FORBIDDEN_ERROR, "无权限下载");

        // 获取文件路径 仅需存储路径
        String filePath = picture.getUrl();

        // 生成预签名URL
        String presignedUrl = fileManager.generatePresignedUrl(filePath);

        return ResultUtils.success(presignedUrl);
    }

    /**
     * 删除图片
     */
    @PostMapping("/delete")
    @PreAuthorizeRole("USER")
    public BaseResponse<Boolean> deletePicture(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {

        ThrowUtils.throwIf(deleteRequest == null || deleteRequest.getId() <= 0, ErrorCode.PARAMS_ERROR, "请求参数错误");

        LoginUserVO loginUser = userService.getLoginUser(request);
        long id = deleteRequest.getId();

        Picture oldPicture = pictureService.getById(id);
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片信息不存在");

        // 仅本人或管理员可删除
        ThrowUtils.throwIf(!oldPicture.getUserId().equals(loginUser.getId())
                        && !loginUser.getRoles().contains(ADMIN_ROLE),
                ErrorCode.NO_AUTH_ERROR, "无权限删除他人图片信息");

        boolean isDelete = pictureService.deletePicture(id, oldPicture.getUrl());

        return ResultUtils.success(isDelete);
    }

    /**
     * 更新图片(仅管理员可用)
     */
    @PostMapping("/update")
    @PreAuthorizeRole("ADMIN")
    public BaseResponse<Boolean> updatePicture(@RequestBody PictureUpdateRequest pictureUpdateRequest, HttpServletRequest request) {

        ThrowUtils.throwIf(pictureUpdateRequest == null || pictureUpdateRequest.getId() <= 0 || request == null
                , ErrorCode.PARAMS_ERROR, "请求参数错误");

        LoginUserVO loginUser = userService.getLoginUser(request);

        long id = pictureUpdateRequest.getId();
        Picture oldPicture = pictureService.getById(id);
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        boolean result = pictureService.updatePicture(pictureUpdateRequest, oldPicture, loginUser);

        return ResultUtils.success(result);
    }

    /**
     * 根据 id 获取图片
     */
    @GetMapping("/get")
    @PreAuthorizeRole("USER")
    public BaseResponse<PictureVO> getPictureById(Long id) {

        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR, "请求参数错误");

        Picture picture = pictureService.getById(id);//todo 完善业务逻辑 非管理员只能查属于自己的(一般来说也拿不到别人的图片id) 至于过不过审条件可以忽略 可以看到自己的没过审的图及原因
        ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        PictureVO pictureVO = pictureService.convertToPictureVO(picture);

        return ResultUtils.success(pictureVO);
    }

    /**
     * 分页获取图片列表(管理员使用)
     */
    @PostMapping("/list")
    @PreAuthorizeRole("ADMIN")
    public BaseResponse<Page<Picture>> listPictureByPage(
            @RequestBody @Valid PictureQueryRequest pictureQueryRequest) {

        long page = pictureQueryRequest.getPage();
        long size = pictureQueryRequest.getPageSize();
        ThrowUtils.throwIf(page <= 0 || size <= 0, ErrorCode.PARAMS_ERROR, "分页参数错误");

        Page<Picture> picturePage = pictureService.page(new Page<>(page, size),
                pictureService.getQueryWrapper(pictureQueryRequest));

        return ResultUtils.success(picturePage);
    }

    /**
     * 游标查询获取图片列表(供用户使用)
     */
    @PostMapping("/listByCursor")
    @PreAuthorizeRole("USER")
    public BaseResponse<PictureCursorResponse> listPicturesByCursor(
            @RequestBody @Valid PictureCursorRequest pictureCursorRequest) {

        PictureCursorResponse pictureCursorResponse = pictureService.listPicturesByCursor(pictureCursorRequest);

        return ResultUtils.success(pictureCursorResponse);
    }

    /**
     * 编辑图片(给用户使用)
     */
    @PostMapping("/edit")
    @PreAuthorizeRole("USER")
    public BaseResponse<Boolean> editPicture(
            @RequestBody @Valid PictureEditRequest pictureEditRequest,
            HttpServletRequest request) {

        ThrowUtils.throwIf(pictureEditRequest == null || pictureEditRequest.getId() <= 0,
                ErrorCode.PARAMS_ERROR, "请求参数错误");

        LoginUserVO loginUser = userService.getLoginUser(request);

        long id = pictureEditRequest.getId();
        Picture oldPicture = pictureService.getById(id);
        ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");

        ThrowUtils.throwIf(!oldPicture.getUserId().equals(loginUser.getId())
                        && !loginUser.getRoles().contains(ADMIN_ROLE),
                ErrorCode.NO_AUTH_ERROR, "无权编辑图片信息");

        // 处理图片信息
        Picture picture = new Picture();
        BeanUtils.copyProperties(pictureEditRequest, picture);
        if (pictureEditRequest.getTags() != null) {
            picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));
        }
        picture.setEditTime(new Date());

        pictureService.validPicture(picture);

        pictureService.fillReviewParams(picture, loginUser);

        boolean result = pictureService.updateById(picture);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片编辑失败");

        return ResultUtils.success(true);
    }

    /**
     * 默认热门标签和热门分类
     */
    @GetMapping("/tag_category")
    @Anonymous
    public BaseResponse<PictureTagCategory> listPictureTagCategory() {
        PictureTagCategory pictureTagCategory = new PictureTagCategory();
        List<String> tagList = Arrays.asList("热门", "搞笑", "生活", "高清", "艺术", "校园", "背景", "简历", "创意");
        List<String> categoryList = Arrays.asList("模板", "电商", "表情包", "素材", "海报");
        pictureTagCategory.setTagList(tagList);
        pictureTagCategory.setCategoryList(categoryList);
        return ResultUtils.success(pictureTagCategory);
    }

    @PostMapping("/review")
    @PreAuthorizeRole("ADMIN")
    public BaseResponse<Boolean> doPictureReview(@RequestBody @Valid PictureReviewRequest pictureReviewRequest,
                                                 HttpServletRequest request) {
        ThrowUtils.throwIf(pictureReviewRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空");

        LoginUserVO loginUser = userService.getLoginUser(request);

        pictureService.doPictureReview(pictureReviewRequest, loginUser);

        return ResultUtils.success(true);
    }

}

测试

测试流程:

  1. 普通用户上传图片,此时主页看不到这张图片
  2. 管理员操作过审,主页就可以看到这张图片
  3. 管理员操作拒绝,主页将无法看到这张图片

Q:有同学说,用户现在看不到自己被拒绝的图片啊?怎么修改?

A:本项目后续会带大家开发用户个人空间,到时候会完成这个功能。

扩展

1、更多审核策略

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

  1. 内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。
  2. AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。
  3. 分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。
  4. 实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。
  5. 举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。

2、审核通知

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

二、通过 URL 导入图片

需求分析

为了提高上传图片的效率,除了支持上传本地文件外,还可以支持输入一个远程 URL,直接将网上已有的图片导入到我们的系统中。

方案设计

实现原理很简单,但是有一些细节需要注意:

1)下载图片:后端服务器从指定的远程 URL 下载图片到本地临时存储。对于 Java 项目,可以直接使用 Hutool 的 HttpUtil.downloadFile 方法一行代码完成。

2)校验图片:跟验证本地文件一样,需要校验图片的格式、大小等。

传统的校验思路是先把文件下载到本地,再对本地文件进行校验,有没有更节省资源的方法呢?

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

注意此处不能使用 GET 请求,它会获取完整文件。

3)上传图片:将校验通过的图片上传到对象存储服务,生成存储 URL。

之后的流程就都可以复用从本地上传图片的流程了。

后端开发

1、服务开发

先编写通过 URL 上传文件的方法,为了便于开发,直接在 FileManager 类中编写,绝大多数代码跟之前的 uploadPicture 方法一致,只需要改动以下 4 处位置:

  1. 方法接受的参数:之前是 MultipartFile 文件类型,现在是 String 字符串类型
  2. 校验图片:之前是校验文件,现在是校验 URL
  3. 获取文件名称:之前是根据文件获取,现在是根据 URL 获取
  4. 保存临时文件:之前是将 MultipartFile 写入到临时文件,现在是从 URL 下载文件

代码如下:

public UploadPictureResult uploadPictureByUrl(String fileUrl, String uploadPathPrefix) {
    // 校验图片
    // validPicture(multipartFile);
    validPicture(fileUrl);
    // 图片上传地址
    String uuid = RandomUtil.randomString(16);
    // String originFilename = multipartFile.getOriginalFilename();
    String originFilename = FileUtil.mainName(fileUrl);
    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);
        // 上传图片
        // ... 其余代码保持不变
    } 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,代码如下:

package com.zwnsyw.yunpicturebackend.manager.upload;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.NumberUtil;
import com.qcloud.cos.model.PutObjectResult;
import com.qcloud.cos.model.ciModel.persistence.ImageInfo;
import com.zwnsyw.yunpicturebackend.config.CosClientConfig;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.manager.CosManager;
import com.zwnsyw.yunpicturebackend.model.dto.File.UploadPictureResult;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.util.Date;
import java.util.UUID;

@Slf4j
public abstract class PictureUploadTemplate {

    @Resource
    protected CosManager cosManager;

    private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";

    public final UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix) {
        validPicture(inputSource);

        String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        String originFilename = getOriginFilename(inputSource);
        String suffix = FileUtil.getSuffix(originFilename);
        if (suffix == null || suffix.isEmpty()) {
            suffix = "unknown";
        }
        String uploadFilename = String.format("%s_%s.%s",
                DateUtil.format(new Date(), DEFAULT_DATE_FORMAT),
                uuid,
                suffix);
        String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename);

        File file = null;
        try {
            file = File.createTempFile(uploadFilename, null);
            processFile(inputSource, file);

            PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);
            ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();

            return buildResult(originFilename, file, uploadPath, imageInfo);
        } catch (IOException e) {
            log.error("文件传输失败, uploadPath = {}, filepath = {}, filename = {}",
                    uploadPath, file != null ? file.getAbsolutePath() : "null", originFilename, e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件传输失败,检查网络问题");
        } catch (Exception e) {
            log.error("图片上传到对象存储失败, uploadPath = {}, filename = {}",
                    uploadPath, originFilename, e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败,请检查网络问题");
        } finally {
            deleteTempFile(file);
        }
    }

    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 result = new UploadPictureResult();
        int picWidth = imageInfo.getWidth();
        int picHeight = imageInfo.getHeight();
        double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();

        result.setPicName(FileUtil.mainName(originFilename));
        result.setPicWidth(picWidth);
        result.setPicHeight(picHeight);
        result.setPicScale(picScale);
        result.setPicFormat(imageInfo.getFormat());
        result.setPicSize(FileUtil.size(file));
        result.setUrl(uploadPath);
        return result;
    }

    public void deleteTempFile(File file) {
        if (file == null || !file.exists()) {
            return;
        }
        boolean deleteResult = false;
        for (int i = 0; i < 3; i++) {
            deleteResult = file.delete();
            if (deleteResult) break;
        }
        if (!deleteResult) {
            log.error("临时文件删除失败, filepath = {}, exists={}, isFile={}",
                    file.getAbsolutePath(), file.exists(), file.isFile());
        }
    }
}

上述代码中,我们把每个步骤都封装为了一个单独的方法,公共的实现(比如 deleteTempFile)可以直接放到模板中,而不用放到具体的实现类中。

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

2)新建本地图片上传子类 FilePictureUpload,继承模板,并且打上 @Service 注解生成 Bean 实例:

package com.zwnsyw.yunpicturebackend.manager.upload;

import cn.hutool.core.io.FileUtil;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.ThrowUtils;
import org.apache.tika.Tika;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.Set;
import java.util.stream.Stream;

@Service
public class FilePictureUpload extends PictureUploadTemplate {

    @Value("${file.max-size:2097152}") // 默认2MB
    private long MAX_FILE_SIZE;

    @Value("#{'${file.allowed-formats:jpeg,jpg,png,webp}'.split(',')}")
    private Set<String> ALLOW_FORMATS;

    private final Tika tika = new Tika();

    @Override
    protected void validPicture(Object inputSource) {
        MultipartFile file = (MultipartFile) inputSource;
        ThrowUtils.throwIf(file.isEmpty(), ErrorCode.PARAMS_ERROR, "文件不能为空");

        // 校验文件大小
        long fileSize = file.getSize();
        ThrowUtils.throwIf(fileSize > MAX_FILE_SIZE, ErrorCode.PARAMS_ERROR, "文件过大");

        // 校验扩展名
        String extension = FileUtil.getSuffix(file.getOriginalFilename());
        ThrowUtils.throwIf(!ALLOW_FORMATS.contains(extension), ErrorCode.PARAMS_ERROR, "不支持的文件类型");

        // MIME类型验证
        try {
            String mimeType = tika.detect(file.getInputStream());
            ThrowUtils.throwIf(!isValidMimeType(mimeType), ErrorCode.PARAMS_ERROR, "文件类型检测失败");
        } catch (IOException e) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件类型检测异常");
        }
    }

    private boolean isValidMimeType(String mimeType) {
        return Stream.of("image/jpeg", "image/png", "image/webp")
                .anyMatch(m -> m.equals(mimeType));
    }

    @Override
    protected String getOriginFilename(Object inputSource) {
        String originalFilename = ((MultipartFile) inputSource).getOriginalFilename();
        // 清理路径信息
        return FileUtil.getName(originalFilename);
    }

    @Override
    protected void processFile(Object inputSource, File file) throws Exception {
        MultipartFile multipartFile = (MultipartFile) inputSource;
        multipartFile.transferTo(file);
    }

}

3)新建 URL 图片上传子类 UrlPictureUpload,继承模板,并且打上 @Service 注解生成 Bean 实例:

package com.zwnsyw.yunpicturebackend.manager.uploadByTemp;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.*;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.ThrowUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Set;

@Service
public class UrlPictureUpload extends PictureUploadTemplate {

    @Value("${file.max-size:2097152}")
    private long MAX_FILE_SIZE;

    @Value("#{'${file.allowed-formats:jpeg,jpg,png,webp}'.split(',')}")
    private Set<String> ALLOW_FORMATS;

    private String fileExtension;

    @Override
    protected void validPicture(Object inputSource) {
        String fileUrl = (String) inputSource;
        ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址不能为空");

        // 验证 URL 格式
        URL url = null;
        try {
            url = new URL(fileUrl);
        } catch (MalformedURLException e) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件地址格式不正确");
        }

        // 校验 URL 协议
        String protocol = url.getProtocol().toLowerCase();
        ThrowUtils.throwIf(!(protocol.equals("http") || protocol.equals("https")),
                ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址");

        // 发送 HEAD 请求以验证文件是否存在
        HttpResponse response = null;
        try {
            response = HttpUtil.createRequest(Method.HEAD, fileUrl)
                    .setReadTimeout(5000)
                    .execute();
        } catch (HttpException e) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "请求文件时发生错误:" + e.getMessage());
        }

        try {
            // 检查响应状态码
            int statusCode = response.getStatus();
            ThrowUtils.throwIf(statusCode != HttpStatus.HTTP_OK,
                    ErrorCode.NOT_FOUND_ERROR, "文件不存在或无法访问(状态码:" + statusCode + ")");

            // 校验文件类型
            String contentType = response.header("Content-Type");
            ThrowUtils.throwIf(StrUtil.isBlank(contentType),
                    ErrorCode.PARAMS_ERROR, "文件类型信息缺失");

            this.fileExtension = getExtensionFromContentType(contentType);
            if (this.fileExtension == null || this.fileExtension.isEmpty()) {
                this.fileExtension = getExtensionFromUrl(fileUrl);
            }

            if (fileExtension != null) {
                ThrowUtils.throwIf(
                        !ALLOW_FORMATS.contains(fileExtension.substring(1)), // 去除前导点
                        ErrorCode.PARAMS_ERROR, "文件类型错误"
                );
            }

            // 校验文件大小
            String contentLengthStr = response.header("Content-Length");
            ThrowUtils.throwIf(StrUtil.isBlank(contentLengthStr),
                    ErrorCode.PARAMS_ERROR, "文件大小信息缺失");

            try {
                long contentLength = Long.parseLong(contentLengthStr);
                ThrowUtils.throwIf(contentLength <= 0 || contentLength > MAX_FILE_SIZE,
                        ErrorCode.PARAMS_ERROR, "文件大小必须大于0且不超过2M");
            } catch (NumberFormatException e) {
                throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式错误");
            }
        } finally {
            if (response != null) {
                response.close();
            }
        }
    }

    private String getExtensionFromContentType(String contentType) {
        if (contentType == null) return null;
        String type = contentType.split(";")[0].trim().toLowerCase();
        switch (type) {
            case "image/jpeg":
            case "image/jpg":
                return ".jpg";
            case "image/png":
                return ".png";
            case "image/webp":
                return ".webp";
            default:
                return null;
        }
    }

    private String getExtensionFromUrl(String fileUrl) {
        try {
            URL url = new URL(fileUrl);
            String query = url.getQuery();
            if (query == null) return null;

            for (String param : query.split("&")) {
                if (param.startsWith("f=")) {
                    String ext = param.split("=")[1].toLowerCase();
                    return "." + ext;
                }
            }
        } catch (MalformedURLException e) {
            return null;
        }
        return null;
    }

    @Override
    protected String getOriginFilename(Object inputSource) {
        String fileUrl = (String) inputSource;
        String filename = FileUtil.getName(fileUrl);
         String baseName = FileUtil.mainName(filename);

        filename = baseName + fileExtension;
        ThrowUtils.throwIf(
                filename.contains("/") || filename.contains("\\"),
                ErrorCode.PARAMS_ERROR, "文件名包含非法路径分隔符"
        );
        return filename;
    }

    @Override
    protected void processFile(Object inputSource, File file) throws Exception {
        String fileUrl = (String) inputSource;
        HttpUtil.downloadFile(fileUrl, file);
    }
}
Bysteam
PictureUploadBySteamTemplate.java
package com.zwnsyw.yunpicturebackend.manager.uploadBySteam;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.NumberUtil;
import com.qcloud.cos.model.PutObjectResult;
import com.qcloud.cos.model.ciModel.persistence.ImageInfo;
import com.zwnsyw.yunpicturebackend.config.CosClientConfig;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.manager.CosManager;
import com.zwnsyw.yunpicturebackend.model.dto.File.UploadPictureResult;
import lombok.extern.slf4j.Slf4j;

import javax.annotation.Resource;
import java.io.IOException;
import java.io.InputStream;
import java.util.Date;
import java.util.UUID;

@Slf4j
public abstract class PictureUploadBySteamTemplate {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    protected CosManager cosManager;

    private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";

    public final UploadPictureResult uploadPicture(Object inputSource, String uploadPathPrefix) {
        validPicture(inputSource);

        // 生成上传路径
        String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        String originFilename = getOriginFilename(inputSource);
        String suffix = FileUtil.getSuffix(originFilename);
        if (suffix == null || suffix.isEmpty()) {
            suffix = ".unknown";
        }
        String uploadFilename = String.format(
                "%s_%s.%s",
                DateUtil.format(new Date(), DEFAULT_DATE_FORMAT),
                uuid,
                suffix
        );
        String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename);

        try (InputStream inputStream = getInputStream(inputSource)) {
            // 获取元数据
            long contentLength = getContentLength(inputSource);
            String contentType = getContentType(inputSource);

            // 执行流式上传
            PutObjectResult putObjectResult = cosManager.putPictureObject(
                    uploadPath,
                    inputStream,
                    contentLength,
                    contentType
            );
            ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();

            return buildResult(
                    originFilename,
                    uploadPath,
                    imageInfo,
                    contentLength
            );
        } catch (IOException | BusinessException e) {
            log.error("文件传输失败, uploadPath = {}, filename = {}",
                    uploadPath, originFilename, e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件传输失败,检查网络问题");
        } catch (Exception e) {
            log.error("图片上传到对象存储失败, uploadPath = {}, filename = {}",
                    uploadPath, originFilename, e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败,请检查网络问题");
        }
    }

    // 新增抽象方法
    protected abstract InputStream getInputStream(Object inputSource) throws Exception;

    protected abstract long getContentLength(Object inputSource);

    protected abstract String getContentType(Object inputSource);

    protected abstract String getOriginFilename(Object inputSource);

    protected abstract void validPicture(Object inputSource);

    // 构建结果方法
    private UploadPictureResult buildResult(
            String originFilename,
            String uploadPath,
            ImageInfo imageInfo,
            long contentLength
    ) {
        UploadPictureResult result = new UploadPictureResult();
        int picWidth = imageInfo.getWidth();
        int picHeight = imageInfo.getHeight();
        double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();

        result.setPicName(FileUtil.mainName(originFilename));
        result.setPicWidth(picWidth);
        result.setPicHeight(picHeight);
        result.setPicScale(picScale);
        result.setPicFormat(imageInfo.getFormat());
        result.setPicSize(contentLength);
        result.setUrl(uploadPath);
        return result;
    }
}
FilePictureUploadBySteam.java
package com.zwnsyw.yunpicturebackend.manager.uploadBySteam;

import cn.hutool.core.io.FileUtil;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.ThrowUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.util.Set;

@Service
public class FilePictureUploadBySteam extends PictureUploadBySteamTemplate {

    @Value("${file.max-size:2097152}")
    private long MAX_FILE_SIZE;

    @Value("#{'${file.allowed-formats:jpeg,jpg,png,webp}'.split(',')}")
    private Set<String> ALLOW_FORMATS;

    @Override
    protected void validPicture(Object inputSource) {
        MultipartFile file = (MultipartFile) inputSource;
        ThrowUtils.throwIf(file.isEmpty(), ErrorCode.PARAMS_ERROR, "文件不能为空");

        // 校验文件大小
        long fileSize = file.getSize();
        ThrowUtils.throwIf(fileSize > MAX_FILE_SIZE, ErrorCode.PARAMS_ERROR, "文件过大");

        // 校验扩展名
        String extension = FileUtil.getSuffix(file.getOriginalFilename());
        ThrowUtils.throwIf(!ALLOW_FORMATS.contains(extension), ErrorCode.PARAMS_ERROR, "不支持的文件类型");

    }

    @Override
    protected String getOriginFilename(Object inputSource) {
        return FileUtil.getName(((MultipartFile) inputSource).getOriginalFilename());
    }

    @Override
    protected InputStream getInputStream(Object inputSource) throws IOException {
        return ((MultipartFile) inputSource).getInputStream();
    }

    @Override
    protected long getContentLength(Object inputSource) {
        return ((MultipartFile) inputSource).getSize();
    }

    @Override
    protected String getContentType(Object inputSource) {
        return ((MultipartFile) inputSource).getContentType();
    }

}
UrlPictureUploadBySteam.java
package com.zwnsyw.yunpicturebackend.manager.uploadBySteam;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.*;
import com.zwnsyw.yunpicturebackend.exception.BusinessException;
import com.zwnsyw.yunpicturebackend.exception.ErrorCode;
import com.zwnsyw.yunpicturebackend.exception.ThrowUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Set;

@Service
public class UrlPictureUploadBySteam extends PictureUploadBySteamTemplate {

    @Value("${file.max-size:2097152}")
    private long MAX_FILE_SIZE;

    @Value("#{'${file.allowed-formats:jpeg,jpg,png,webp}'.split(',')}")
    private Set<String> ALLOW_FORMATS;

    private transient HttpResponse cachedResponse; // 缓存响应

    private String fileExtension;

    @Override
    protected void validPicture(Object inputSource) {
        String fileUrl = (String) inputSource;
        ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址不能为空");

        // 验证 URL 格式
        URL url;
        try {
            url = new URL(fileUrl);
        } catch (MalformedURLException e) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件地址格式不正确");
        }

        // 校验 URL 协议
        String protocol = url.getProtocol().toLowerCase();
        ThrowUtils.throwIf(!(protocol.equals("http") || protocol.equals("https")),
                ErrorCode.PARAMS_ERROR, "仅支持 HTTP 或 HTTPS 协议的文件地址");

        // 发送 HEAD 请求并缓存结果
        try {
            cachedResponse = HttpUtil.createRequest(Method.HEAD, fileUrl)
                    .setReadTimeout(5000)
                    .execute();
        } catch (HttpException e) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "请求文件时发生错误:" + e.getMessage());
        }

        try {
            int statusCode = cachedResponse.getStatus();
            ThrowUtils.throwIf(statusCode != HttpStatus.HTTP_OK,
                    ErrorCode.NOT_FOUND_ERROR, "文件不存在或无法访问(状态码:" + statusCode + ")");

            String contentType = cachedResponse.header("Content-Type");
            ThrowUtils.throwIf(StrUtil.isBlank(contentType),
                    ErrorCode.PARAMS_ERROR, "文件类型信息缺失");

            this.fileExtension = getExtensionFromContentType(contentType);
            if (this.fileExtension == null || this.fileExtension.isEmpty()) {
                this.fileExtension = getExtensionFromUrl(fileUrl);
            }

            if (fileExtension != null) {
                ThrowUtils.throwIf(
                        !ALLOW_FORMATS.contains(fileExtension.substring(1)), // 去除前导点
                        ErrorCode.PARAMS_ERROR, "文件类型错误"
                );
            }

            String contentLengthStr = cachedResponse.header("Content-Length");
            ThrowUtils.throwIf(StrUtil.isBlank(contentLengthStr),
                    ErrorCode.PARAMS_ERROR, "文件大小信息缺失");

            long contentLength = Long.parseLong(contentLengthStr);
            ThrowUtils.throwIf(contentLength <= 0 || contentLength > MAX_FILE_SIZE,
                    ErrorCode.PARAMS_ERROR, "文件大小必须大于0且不超过2M");

        } finally {
            if (cachedResponse != null) {
                cachedResponse.close();
            }
        }
    }

    private String getExtensionFromContentType(String contentType) {
        if (contentType == null) return null;
        String type = contentType.split(";")[0].trim().toLowerCase();
        switch (type) {
            case "image/jpeg":
            case "image/jpg":
                return ".jpg";
            case "image/png":
                return ".png";
            case "image/webp":
                return ".webp";
            default:
                return null;
        }
    }

    private String getExtensionFromUrl(String fileUrl) {
        try {
            URL url = new URL(fileUrl);
            String query = url.getQuery();
            if (query == null) return null;

            for (String param : query.split("&")) {
                if (param.startsWith("f=")) {
                    String ext = param.split("=")[1].toLowerCase();
                    return "." + ext;
                }
            }
        } catch (MalformedURLException e) {
            return null;
        }
        return null;
    }

    @Override
    protected String getOriginFilename(Object inputSource) {
        String fileUrl = (String) inputSource;
        String filename = FileUtil.getName(fileUrl);
         String baseName = FileUtil.mainName(filename);

        filename = baseName + fileExtension;
        ThrowUtils.throwIf(
                filename.contains("/") || filename.contains("\\"),
                ErrorCode.PARAMS_ERROR, "文件名包含非法路径分隔符"
        );
        return filename;
    }

    @Override
    protected InputStream getInputStream(Object inputSource) throws IOException {
        return HttpUtil.createGet((String) inputSource).execute().bodyStream();
    }

    @Override
    protected long getContentLength(Object inputSource) {
        return Long.parseLong(cachedResponse.header("Content-Length"));
    }

    @Override
    protected String getContentType(Object inputSource) {
        return cachedResponse.header("Content-Type");
    }

}

优化完后,可以还原 FileManager 文件,并添加 @Deprecated 注解表示已废弃,后续将直接使用文件上传模板类 PictureUploadTemplate。

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

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

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

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

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

@Resource
private FilePictureUpload filePictureUpload;

@Resource
private UrlPictureUpload urlPictureUpload;

@Resource
private FilePictureUploadBySteam filePictureUploadBySteam;

@Resource
private UrlPictureUploadBySteam urlPictureUploadBySteam;

@Override
    public PictureVO uploadPicture(Object inputSource, PictureUploadRequest
            pictureUploadRequest, LoginUserVO loginUser) {

        if (inputSource == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "图片为空");
        }

        // 判断是新增还是更新图片
        Long pictureId = pictureUploadRequest != null ? pictureUploadRequest.getId() : null;

        // 如果是更新图片,需要校验图片是否存在
        if (pictureId != null) {
            Picture oldPicture = this.getById(pictureId);
            ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
            if (!oldPicture.getUserId().equals(loginUser.getId()) && !loginUser.getRoles().contains(ADMIN_ROLE)) {
                throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限修改他人图片信息");
            }
        }

        // 上传图片,得到信息  按照用户 id 划分目录
        String uploadPathPrefix = String.format("public/%s", loginUser.getId());

      // 根据 inputSource 类型区分上传方式
//        PictureUploadTemplate pictureUploadTemplate = (inputSource instanceof String) ? urlPictureUpload : filePictureUpload;
//        UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);

        PictureUploadBySteamTemplate pictureUploadBySteamTemplate = (inputSource instanceof String) ? urlPictureUploadBySteam : filePictureUploadBySteam;
        UploadPictureResult uploadPictureResult = pictureUploadBySteamTemplate.uploadPicture(inputSource, uploadPathPrefix);

        // 构造要入库的图片信息
        Picture picture = new Picture();
        picture.setUrl(uploadPictureResult.getUrl());       // to do Done 思考 存完整url还是url后半部分?如果换云服务的话 服务器内部数据直接做迁移 url前部分变 后部分不变 但数据库中就不好修改了
        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());

        this.fillReviewParams(picture, loginUser);

        Date currentTime = new Date();
        if (pictureId != null) {
            picture.setId(pictureId);
            picture.setEditTime(currentTime);

            // 删除旧图片
            Picture oldPicture = this.getById(pictureId);
            if (oldPicture != null && oldPicture.getUrl() != null) {
                fileManager.deletePicture(getFullUrl(oldPicture.getUrl()));
            }
        }

        boolean result = this.saveOrUpdate(picture);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败");

        // 重新查询数据库以确保数据一致性
        Picture newPicture = this.getById(picture.getId());
        return convertToPictureVO(newPicture);
    }

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

5、接口开发

1)在请求封装类 PictureUploadRequest 中新增 fileUrl 文件地址:

package com.zwnsyw.yunpicturebackend.model.dto.PictureRequest;

import lombok.Data;
import org.hibernate.validator.constraints.URL;

import javax.validation.constraints.NotBlank;
import java.io.Serializable;

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

    /**
     * 图片 url(可用url上传)
     */
    @NotBlank(message = "文件地址不能为空")
    @URL(message = "文件地址格式不正确")
    private String fileUrl;

    private static final long serialVersionUID = 1L;
}

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

 /**
     * 通过 URL 上传图片(可重新上传)
     */
    @PostMapping("/uploadByUrl")
    @PreAuthorizeRole("USER")
    public BaseResponse<PictureVO> uploadPictureByUrl(
            @RequestBody @Valid PictureUploadRequest pictureUploadRequest,
            HttpServletRequest request) {
        LoginUserVO loginUser = userService.getLoginUser(request);
        String fileUrl = pictureUploadRequest.getFileUrl();
        PictureVO pictureVO = pictureService.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
        return ResultUtils.success(pictureVO);
    }

然后可以通过 Swagger 接口文档测试本地文件图片和 URL 图片的上传

前端开发

和本地上传图片的开发流程一样,我们先来开发一个 URL 上传图片的组件,绝大多数代码都可以复用本地上传图片组件。

1、URL 上传组件

URL 上传组件 = 文本输入框 + 提交按钮

可以使用组件库的 复合输入框组件

<div class="url-picture-upload">
  <a-input-group compact style="margin-bottom: 16px">
    <a-input v-model:value="fileUrl" style="width: calc(100% - 120px)" placeholder="请输入图片 URL" />
    <a-button type="primary" :loading="loading" @click="handleUpload" style="width: 120px">提交</a-button>
  </a-input-group>
  <img v-if="picture?.url" :src="api/images/3YLabHUCos3M/picture?.url" alt="avatar" />
</div>

开发上传操作函数,需要将用户输入的 fileUrl 提交到后端:

<template>
  <div class="url-picture-upload">
    <a-input-group compact>
      <a-input
        v-model:value="fileUrl"
        style="width: calc(100% - 120px)"
        placeholder="请输入图片地址"
      />
      <a-button type="primary" style="width: 120px" :loading="loading" @click="handleUpload">
        提交
      </a-button>
    </a-input-group>
    <div class="img-wrapper">
      <img v-if="picture?.url" :src="api/images/3YLabHUCos3M/picture?.url" alt="avatar" />
    </div>
  </div>
</template>

import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { uploadPictureByUrlUsingPost } from '@/api/pictureController.ts'

interface Props {
  picture?: API.PictureVO
  onSuccess?: (newPicture: API.PictureVO) => void
}

const props = defineProps<Props>()
const fileUrl = ref<string>('') // 初始化为空字符串
const loading = ref<boolean>(false)

const handleUpload = async () => {
  if (!fileUrl.value) { // 校验输入是否为空
    message.warning('请输入图片地址')
    return
  }

  loading.value = true
  try {
    const params: API.PictureUploadRequest = { fileUrl: fileUrl.value }
    // 安全获取props.picture.id
    if (props.picture && props.picture.id) {
      params.id = props.picture.id
    }
    const res = await uploadPictureByUrlUsingPost(params)
    if (res.data.code === 0 && res.data.data) {
      message.success('图片上传成功')
      // 确保数据存在再调用父组件方法
      props.onSuccess?.(res.data.data)
    } else {
      message.error('图片上传失败,' + res.data.description)
    }
  } catch (error) {
    console.error('图片上传失败', error)
    message.error('图片上传失败,' + (error.message || '未知错误')) // 显示更友好的错误信息
  } finally {
    loading.value = false // 确保loading状态最终关闭
  }
}


.url-picture-upload {
  margin-bottom: 16px;
}

.url-picture-upload img {
  max-width: 100%;
  max-height: 480px;
}

.url-picture-upload .img-wrapper {
  text-align: center;
  margin-top: 16px;
}

2、开发创建页面

之前已经开发了创建图片页面,可以在上传图片时增加一个 Tabs 选项组件,让用户自己选择上传方式。

<template>
  <div id="addPicturePage">
    <h2 style="margin:16px auto">
      {{ route.query?.id ? '修改图片' : '创建图片' }}
    </h2>

    <!-- 选择上传方式 -->
    <a-tabs v-model:activeKey="uploadType">
      <a-tab-pane key="file" tab="文件上传">
        <!-- 图片上传组件 -->
        <PictureUpload :picture="picture" :onSuccess="onSuccess" />
      </a-tab-pane>
      <a-tab-pane key="url" tab="URL 上传" force-render>
        <!-- URL 图片上传组件 -->
        <UrlPictureUpload :picture="picture" :onSuccess="onSuccess" />
      </a-tab-pane>
    </a-tabs>

    <a-form
      v-if="picture"
      name="pictureForm"
      layout="vertical"
      :model="pictureForm"
      @finish="handleSubmit"
    >
      <a-form-item name="name" label="名称">
        <a-input v-model:value="pictureForm.name" placeholder="请输入名称" allow-clear />
      </a-form-item>
      <a-form-item name="introduction" label="简介">
        <a-textarea
          v-model:value="pictureForm.introduction"
          placeholder="请输入简介"
          :auto-size="{ minRows: 2, maxRows: 5 }"
          allow-clear
        />
      </a-form-item>
      <a-form-item name="category" label="分类">
        <a-auto-complete
          v-model:value="pictureForm.category"
          placeholder="请输入分类"
          :options="categoryOptions"
          allow-clear
        />
      </a-form-item>
      <a-form-item name="tags" label="标签">
        <a-select
          v-model:value="pictureForm.tags"
          mode="tags"
          placeholder="请输入标签"
          :options="tagOptions"
          allow-clear
        />
      </a-form-item>
      <a-form-item>
        <a-button type="primary" html-type="submit" style="width: 100%">确定</a-button>
      </a-form-item>
    </a-form>
  </div>
</template>


import PictureUpload from '@/components/PictureUpload.vue'
import UrlPictureUpload from '@/components/UrlPictureUpload.vue'
import { onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
  editPictureUsingPost,
  getPictureByIdUsingGet,
  listPictureTagCategoryUsingGet
} from '@/api/pictureController.ts'
import { useRoute, useRouter } from 'vue-router'

const picture = ref<API.PictureVO>()
const pictureForm = reactive<API.PictureEditRequest>({})
const uploadType = ref<'file' | 'url'>('file')

/**
 * 图片上传成功
 * @param newPicture
 */
const API.PictureVO) => {
  picture.value = newPicture
  pictureForm.name = newPicture.name
}

const router = useRouter()

/**
 * 提交表单
 * @param values
 */
const handleSubmit = async (values: any) => {
  console.log(values)
  const pictureId = picture.value.id
  if (!pictureId) {
    return
  }
  const res = await editPictureUsingPost({
    id: pictureId,
    ...values,
  })
  // 操作成功
  if (res.data.code === 0 && res.data.data) {
    message.success('创建成功')
    // 跳转到图片详情页
    router.push({
      path: `/picture/${pictureId}`,
    })
  } else {
    message.error('创建失败,' + res.data.message)
  }
}

const categoryOptions = ref<string[]>([])
const tagOptions = ref<string[]>([])

/**
 * 获取标签和分类选项
 * @param values
 */
const getTagCategoryOptions = async () => {
  const res = await listPictureTagCategoryUsingGet()
  if (res.data.code === 0 && res.data.data) {
    tagOptions.value = (res.data.data.tagList ?? []).map((data: string) => {
      return {
        value: data,
        label: data,
      }
    })
    categoryOptions.value = (res.data.data.categoryList ?? []).map((data: string) => {
      return {
        value: data,
        label: data,
      }
    })
  } else {
    message.error('获取标签分类列表失败,' + res.data.message)
  }
}

onMounted(() => {
  getTagCategoryOptions()
})

const route = useRoute()

// 获取老数据
const getOldPicture = async () => {
  // 获取到 id
  const id = route.query?.id
  if (id) {
    const res = await getPictureByIdUsingGet({
      id,
    })
    if (res.data.code === 0 && res.data.data) {
      const data = res.data.data
      picture.value = data
      pictureForm.name = data.name
      pictureForm.introduction = data.introduction
      pictureForm.category = data.category
      pictureForm.tags = data.tags
    }
  }
}

onMounted(() => {
  getOldPicture()
})



#addPicturePage {
  max-width: 720px;
  margin: 0 auto;
}

定义上传类型变量:

const uploadType = ref<'file' | 'url'>('file')

其他代码都不需要调整。你会发现只要开发思路清晰、代码结构良好,新功能的扩展是很快的

批量抓取和创建图片

需求分析

为了帮助管理员快速丰富图片库,冷启动项目,需要提供批量从网络抓取并创建图片的功能。

但是要注意,不建议将该功能开放给普通用户!主要是为了防止滥用导致的版权问题、低质量内容的上传、服务器资源消耗和安全问题。因为我们要从网络批量抓取图片(爬虫),如果功能开放给用户,相当于所有用户都在使用我们的服务器作为爬虫源头,容易导致我们的服务器 IP 被封禁。

方案设计

方案设计的重点包括:

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

1、如何抓取图片?

思考 2 个问题:从哪里抓取图片?怎么抓取图片呢?

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

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

bibULWXkn8YRera2-d408069c

有 2 种常见的做法,第一种是请求到完整的页面内容后,对页面的 HTML 结构进行解析,提取到图片的地址,再通过 URL 下载;还有一种是直接调用后端获取图片地址的接口拿到图片数据。

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

按 F12 打开网络请求控制台,向下滚动图片时会触发新一波图片的加载,就能看到获取图片数据的接口了:https://cn.bing.com/images/async?q=%s&mmasync=1

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

OjWxSydd2AUZFfzg-cbc931d0 uiThuLEhEhDSF3jE-cbc931d0

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

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

xwQsxcdEPhi6yL94-ae37b78b

注意,图片的地址后面有很多附加参数,比如 ?w=199&h=180,在导入图片时一定要移除!否则会影响图片的质量,还有可能导致上传到对象存储的文件包含被转义的特殊字符,引发无法访问等问题。

2、抓取和导入规则

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

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

后端开发

1、定义请求体

model.dto.picture 包下新建 PictureUploadByBatchRequest:

package com.zwnsyw.yunpicturebackend.model.dto.PictureRequest;

import lombok.Data;

import javax.validation.constraints.AssertTrue;

import javax.validation.constraints.Min;

import javax.validation.constraints.NotBlank;

import javax.validation.constraints.NotNull;

import java.io.Serializable;

@Data

public class PictureUploadByBatchRequest implements Serializable {

    /**

     * 搜索词

     */

    @NotBlank(message = "搜索词不能为空")

    private String searchText;

    /**

     * 抓取bing起始页(从1开始)

     */

    @NotNull(message = "起始页不能为空")

    @Min(value = 1, message = "起始页至少为1")

    private Integer startPage = 1;

    /**

     * 抓取bing结束页(必须 ≥ 起始页)

     */

    @NotNull(message = "结束页不能为空")

    @Min(value = 1, message = "结束页至少为1")

    private Integer endPage = 1;

    private static final long serialVersionUID = 1L;

    /**

     * 校验页码范围:

     * 1. 结束页 ≥ 起始页

     * 2. 页码差 ≤3(最多抓取3页)

     */

    @AssertTrue(message = "结束页必须大于等于起始页且最多只能抓取3页")

    public boolean isPageRangeValid() {

        return endPage >= startPage && (endPage - startPage) <= 3;

    }

}

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 抓取详情

     */

    PictureUploadByBatchResponse uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, LoginUserVO 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;
}
 /**

     * 简化逻辑

     * bing中一页为35张

     * 传入起始页 结束页 抓取这些页面的图片信息

     * 解析成形如:

     * https://cn.bing.com/images/async?q=风景&first=0&count=35&mmasync=1

     * https://cn.bing.com/images/async?q=风景&first=35&count=35&mmasync=1

     * 的请求 从中爬取图片信息并上传

     * 至于最后能成功上传几张 随缘

     * 我们只需最后返回 类似 已上传19张图片 本次共抓取 35 张图片,成功 19 张,失败 16 张  这样的信息

     */

    @Override

    public PictureUploadByBatchResponse uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, LoginUserVO loginUser) {

        String searchText = pictureUploadByBatchRequest.getSearchText();

        int startPage = pictureUploadByBatchRequest.getStartPage();

        int endPage = pictureUploadByBatchRequest.getEndPage();

        List<String> allImageUrls = new ArrayList<>();

        List<String> failedUrls = new ArrayList<>();

        // 遍历每一页

        for (int page = startPage; page <= endPage; page++) {

            int firstIndex = (page - 1) * 35; // 计算起始索引(第1页为0)

            String fetchUrl = String.format(

                    "https://cn.bing.com/images/async?q=%s&first=%d&count=35&mmasync=1",

                    searchText,

                    firstIndex

            );

            try {

                Connection conn = Jsoup.connect(fetchUrl)

                        .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36")

                        .referrer("https://www.bing.com/");

                Document document = conn.get();

                Element div = document.getElementsByClass("dgControl").first();

                ThrowUtils.throwIf(ObjUtil.isNull(div), ErrorCode.OPERATION_ERROR, "获取元素失败");

                Elements imgElementList = div.select("img.mimg");

                log.info("第{}页抓取到 {} 张图片", page, imgElementList.size());

                // 收集有效URL

                for (Element imgElement : imgElementList) {

                    String fileUrl = imgElement.attr("src");

                    if (StrUtil.isNotBlank(fileUrl) && fileUrl.startsWith("http")) {

                        int questionMarkIndex = fileUrl.indexOf('?');

                        if (questionMarkIndex > -1) {

                            fileUrl = fileUrl.substring(0, questionMarkIndex);

                        }

                        allImageUrls.add(fileUrl);

                    }

                }

            } catch (IOException e) {

                log.error("抓取第{}页失败", page, e);

            }

        }

        // 处理所有收集到的图片URL

        int totalFetched = allImageUrls.size();

        int successCount = 0;

        ExecutorService executor = Executors.newFixedThreadPool(Math.min(10, allImageUrls.size()));

        List<Future<Pair<Integer, String>>> futures = new ArrayList<>();

        for (String fileUrl : allImageUrls) {

            final String finalFileUrl = fileUrl;

            Future<Pair<Integer, String>> future = executor.submit(() -> {

                try {

                    Thread.sleep(100); // 速率限制

                    PictureUploadRequest request = new PictureUploadRequest();

                    PictureVO pictureVO = uploadPicture(finalFileUrl, request, loginUser);

                    log.info("图片上传成功, id = {}", pictureVO.getId());

                    return new Pair<>(1, null);

                } catch (InterruptedException e) {

                    Thread.currentThread().interrupt();

                    return new Pair<>(0, "线程中断");

                } catch (Exception e) {

                    return new Pair<>(0, e.getMessage());

                }

            });

            futures.add(future);

        }

        // 等待所有任务完成并处理超时

        for (Future<Pair<Integer, String>> future : futures) {

            try {

                Pair<Integer, String> result = future.get(60, TimeUnit.SECONDS);

                if (result.getKey() == 1) {

                    successCount++;

                } else {

                    failedUrls.add(result.getValue());

                }

            } catch (TimeoutException e) {

                future.cancel(true); // 取消任务

                failedUrls.add("任务超时");

            } catch (Exception e) {

                log.error("任务处理异常", e);

            }

        }

        executor.shutdown();

        try {

            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {

                executor.shutdownNow();

                if (!executor.awaitTermination(60, TimeUnit.SECONDS))

                    log.error("线程池未终止");

            }

        } catch (InterruptedException ie) {

            executor.shutdownNow();

            Thread.currentThread().interrupt();

        }

        // 记录失败详情

        if (!failedUrls.isEmpty()) {

            log.error("批量上传失败详情:{}", failedUrls);

        }

        log.info("共抓取 {} 张图片,成功 {} 张,失败 {} 张", totalFetched, successCount, totalFetched - successCount);

        String message = String.format(

                "任务上传%d张成功,本次共抓取%d张图片(%d-%d页),成功%d张,失败%d张",

                successCount,

                totalFetched,

                startPage,

                endPage,

                successCount,

                totalFetched - successCount

        );

        return new PictureUploadByBatchResponse(totalFetched, successCount, totalFetched - successCount, message);

    }

上述代码中,我们添加了很多日志记录和异常处理逻辑,使得单张图片抓取或导入失败时任务还能够继续执行,最终返回创建成功的图片数。

💡 如果抓取的内容数量较多,可以适当地 Thread.sleep 阻塞等待一段时间,减少服务器被封禁的概率。

3、开发接口

在 Controller 中新增接口,注意限制仅管理员可用:

  /**

     * 批量抓取和创建图片

     */

    @PostMapping("/uploadByBatch")

    @PreAuthorizeRole("ADMIN")

    public BaseResponse<PictureUploadByBatchResponse> uploadPictureByBatch(

            @RequestBody @Valid PictureUploadByBatchRequest pictureUploadByBatchRequest,

            HttpServletRequest request) {

        ThrowUtils.throwIf(pictureUploadByBatchRequest == null, ErrorCode.PARAMS_ERROR, "请求参数错误");

        LoginUserVO loginUser = userService.getLoginUser(request);

        return ResultUtils.success(pictureService.uploadPictureByBatch(pictureUploadByBatchRequest, loginUser));

    }

4、扩展功能 - 批量设置属性

之前我们导入系统的图片名称都是由对方的 URL 决定的,名称可能乱七八糟,而且不利于我们得知数据是在那一批被导入的。

因此我们可以让管理员在执行任务前指定 名称前缀,即导入到系统中的图片名称。比如前缀为 “鱼皮”,得到的图片名称就是 “鱼皮1”、“鱼皮2”。。。

相当于支持抓取和创建图片时批量对某批图片命名,名称前缀默认等于搜索关键词。

下面来开发实现:

1)给 PictureUploadByBatchRequest 请求包装类补充 namePrefix 分类 标签参数:

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

package com.zwnsyw.yunpicturebackend.model.dto.PictureRequest;

import lombok.Data;

import javax.validation.constraints.AssertTrue;

import javax.validation.constraints.Min;

import javax.validation.constraints.NotBlank;

import javax.validation.constraints.NotNull;

import java.io.Serializable;

import java.util.List;

@Data

public class PictureUploadByBatchRequest implements Serializable {

    /**

     * 搜索词

     */

    @NotBlank(message = "搜索词不能为空")

    private String searchText;

    /**

     * 抓取bing起始页(从1开始)

     */

    @NotNull(message = "起始页不能为空")

    @Min(value = 1, message = "起始页至少为1")

    private Integer startPage = 1;

    /**

     * 抓取bing结束页(必须 ≥ 起始页)

     */

    @NotNull(message = "结束页不能为空")

    @Min(value = 1, message = "结束页至少为1")

    private Integer endPage = 1;

    /**

     * 图片名称前缀(必填)

     */

    @NotBlank(message = "名称前缀不能为空")

    private String namePrefix;

    /**

     * 图片分类(必填)

     */

    @NotBlank(message = "分类不能为空")

    private String category;

    /**

     * 图片标签列表(可选)

     */

    private List<String> tags;

    private static final long serialVersionUID = 1L;

    /**

     * 校验页码范围:

     * 1. 结束页 ≥ 起始页

     * 2. 页码差 ≤3(最多抓取3页)

     */

    @AssertTrue(message = "结束页必须大于等于起始页且最多只能抓取3页")

    public boolean isPageRangeValid() {

        return endPage >= startPage && (endPage - startPage) <= 3;

    }

}

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

4)修改批量抓取和导入图片的服务方法 uploadPictureByBatch,补充图片名称生成逻辑:

 /**

     * 简化逻辑

     * bing中一页为35张

     * 传入起始页 结束页 抓取这些页面的图片信息

     * 解析成形如:

     * https://cn.bing.com/images/async?q=风景&first=0&count=35&mmasync=1

     * https://cn.bing.com/images/async?q=风景&first=35&count=35&mmasync=1

     * 的请求 从中爬取图片信息并上传

     * 至于最后能成功上传几张 随缘

     * 我们只需最后返回 类似 已上传19张图片 本次共抓取 35 张图片,成功 19 张,失败 16 张  这样的信息

     */

    @Override

    public PictureUploadByBatchResponse uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, LoginUserVO loginUser) {

        String searchText = pictureUploadByBatchRequest.getSearchText();

        int startPage = pictureUploadByBatchRequest.getStartPage();

        int endPage = pictureUploadByBatchRequest.getEndPage();

        List<String> allImageUrls = new ArrayList<>();

        List<String> failedUrls = new ArrayList<>();

        // 遍历每一页

        for (int page = startPage; page <= endPage; page++) {

            int firstIndex = (page - 1) * 35; // 计算起始索引(第1页为0)

            String fetchUrl = String.format(

                    "https://cn.bing.com/images/async?q=%s&first=%d&count=35&mmasync=1",

                    searchText,

                    firstIndex

            );

            try {

                Connection conn = Jsoup.connect(fetchUrl)

                        .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36")

                        .referrer("https://www.bing.com/");

                Document document = conn.get();

                Element div = document.getElementsByClass("dgControl").first();

                ThrowUtils.throwIf(ObjUtil.isNull(div), ErrorCode.OPERATION_ERROR, "获取元素失败");

                Elements imgElementList = div.select("img.mimg");

                log.info("第{}页抓取到 {} 张图片", page, imgElementList.size());

                // 收集有效URL

                for (Element imgElement : imgElementList) {

                    String fileUrl = imgElement.attr("src");

                    if (StrUtil.isNotBlank(fileUrl) && fileUrl.startsWith("http")) {

                        int questionMarkIndex = fileUrl.indexOf('?');

                        if (questionMarkIndex > -1) {

                            fileUrl = fileUrl.substring(0, questionMarkIndex);

                        }

                        allImageUrls.add(fileUrl);

                    }

                }

            } catch (IOException e) {

                log.error("抓取第{}页失败", page, e);

            }

        }

        // 处理所有收集到的图片URL

        int totalFetched = allImageUrls.size();

        int successCount = 0;

        ExecutorService executor = Executors.newFixedThreadPool(Math.min(10, allImageUrls.size()));

        List<Future<Pair<Integer, String>>> futures = new ArrayList<>();

        // 计数器(初始值为1)

        AtomicInteger sequence = new AtomicInteger(1);

        for (String fileUrl : allImageUrls) {

            final String finalFileUrl = fileUrl;

            Future<Pair<Integer, String>> future = executor.submit(() -> {

                try {

                    Thread.sleep(100); // 速率限制

                    PictureUploadRequest request = new PictureUploadRequest();

                    PictureVO pictureVO = uploadPicture(finalFileUrl, request, loginUser);

                    Long pictureId = pictureVO.getId();

                    String newName = pictureUploadByBatchRequest.getNamePrefix() + sequence.getAndIncrement();

                    PictureUpdateRequest updateRequest = new PictureUpdateRequest();

                    updateRequest.setId(pictureId);

                    updateRequest.setName(newName);

                    updateRequest.setCategory(pictureUploadByBatchRequest.getCategory());

                    updateRequest.setTags(pictureUploadByBatchRequest.getTags());

                    boolean updateResult = this.updatePicture(updateRequest, loginUser);

                    ThrowUtils.throwIf(!updateResult, ErrorCode.OPERATION_ERROR, "元数据更新失败");

                    log.info("图片上传并更新成功, id = {}", pictureId);

                    return new Pair<>(1, null);

                } catch (InterruptedException e) {

                    Thread.currentThread().interrupt();

                    return new Pair<>(0, "线程中断");

                } catch (Exception e) {

                    return new Pair<>(0, e.getMessage());

                }

            });

            futures.add(future);

        }

        // 等待所有任务完成并处理超时

        for (Future<Pair<Integer, String>> future : futures) {

            try {

                Pair<Integer, String> result = future.get(30, TimeUnit.SECONDS);

                if (result.getKey() == 1) {

                    successCount++;

                } else {

                    failedUrls.add(result.getValue());

                }

            } catch (TimeoutException e) {

                future.cancel(true); // 取消任务

                failedUrls.add("任务超时");

            } catch (Exception e) {

                log.error("任务处理异常", e);

            }

        }

        executor.shutdown();

        try {

            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {

                executor.shutdownNow();

                if (!executor.awaitTermination(60, TimeUnit.SECONDS))

                    log.error("线程池未终止");

            }

        } catch (InterruptedException ie) {

            executor.shutdownNow();

            Thread.currentThread().interrupt();

        }

        // 记录失败详情

        if (!failedUrls.isEmpty()) {

            log.error("批量上传失败详情:{}", failedUrls);

        }

        log.info("共抓取 {} 张图片,成功 {} 张,失败 {} 张", totalFetched, successCount, totalFetched - successCount);

        String message = String.format(

                "任务上传%d张成功,本次共抓取%d张图片(%d-%d页),成功%d张,失败%d张",

                successCount,

                totalFetched,

                startPage,

                endPage,

                successCount,

                totalFetched - successCount

        );

        return new PictureUploadByBatchResponse(totalFetched, successCount, totalFetched - successCount, message);

    }

5、接口测试

可以通过 Swagger 测试批量抓取和创建图片功能

前端开发

可以新建一个批量创建图片页面,并且在图片管理页面补充跳转到该页面的按钮。

1、图片管理页面补充按钮

管理页面补充 “批量创建图片” 按钮,代码如下:

<a-space>
  <a-button type="primary" href="#root/k3AukLPIdBLw" target="_blank">+ 创建图片</a-button>
  <a-button type="primary" href="#root/kT1ashcY7LK5" target="_blank" ghost>+ 批量创建图片</a-button>
</a-space>

2、批量创建图片页面

1)新建页面文件 AddPictureBatchPage.vue(复制创建图片页面),并添加路由:

{
  path: '/add_picture/batch',
  name: '批量创建图片',
  component: AddPictureBatchPage,
}

正常情况下,普通用户是看不见该页面的,即使看见了,也会因为后端的限制无法使用。

2)该页面主体是一个表单,和创建图片页面极为相似,先修改表单项:

<template>

  <div id="addPictureBatchPage" class="container">

    <h2 style="margin: 20px">批量创建</h2>

    <a-form :model="formData" @finish="handleSubmit" layout="vertical">

      <a-form-item name="searchText" label="关键词">

        <a-input v-model:value="formData.searchText" placeholder="请输入关键词" allow-clear />

      </a-form-item>

      <a-form-item name="category" label="分类">

        <a-auto-complete

          v-model:value="formData.category"

          placeholder="请选择分类"

          :options="categoryOptions.map((cat) => ({ value: cat, label: cat }))"

          allow-clear

        />

      </a-form-item>

      <a-form-item name="tags" label="标签">

        <a-select

          mode="tags"

          v-model:value="formData.tags"

          placeholder="请选择标签"

          :options="tagOptions.map((tag) => ({ value: tag, label: tag }))"          style="width: 100%"

        />

      </a-form-item>

      <a-row :gutter="[8, 8]">

        <a-col :span="12">

          <a-form-item name="startPage" label="起始页:">

            <a-input-number v-model:value="formData.startPage" :min="1" style="height: 46px;" />

          </a-form-item>

        </a-col>

        <a-col :span="12">

          <a-form-item name="endPage" label="结束页:">

            <a-input-number v-model:value="formData.endPage" :min="1" style="height: 46px;" />

          </a-form-item>

        </a-col>

      </a-row>

      <a-form-item name="namePrefix" label="名称前缀">

        <a-input v-model:value="formData.namePrefix" placeholder="请输入名称前缀" allow-clear />

      </a-form-item>

      <a-form-item>

        <a-button type="primary" html-type="submit" :loading="loading" style="width: 50%; margin: 20px auto;">

          执行任务

        </a-button>

      </a-form-item>

    </a-form>

    <!-- 使用 v-if 控制进度条的显示 -->

    <a-progress v-if="showProgress" :percent="progressPercent" />

  </div>

</template>



import { onMounted, reactive, ref } from 'vue'

import { message } from 'ant-design-vue'

import { uploadPictureByBatchUsingPost } from '@/api/pictureController.ts'

import { useRoute, useRouter } from 'vue-router'

import { listPictureTagCategoryUsingGet } from '@/api/pictureController.ts'

// 表单数据

const formData = reactive<API.PictureUploadByBatchRequest>({

  category: '',

  endPage: 1,

  namePrefix: '',

  searchText: '',

  startPage: 1,

  tags: [],

})

// 分类和标签选项

const categoryOptions = ref<string[]>([])

const tagOptions = ref<string[]>([])

// 获取预设选项

const getTagCategoryOptions = async () => {

  try {

    const res = await listPictureTagCategoryUsingGet()

    if (res.data.code === 0 && res.data.data) {

      categoryOptions.value = res.data.data.categoryList ?? []

      tagOptions.value = res.data.data.tagList ?? []

    } else {

      message.error('获取标签分类失败:' + res.data.message)

    }

  } catch (error) {

    message.error('获取标签分类失败')

  }

}

onMounted(() => {

  getTagCategoryOptions()

})

// 提交状态

const loading = ref(false)

const router = useRouter()

// 进度条相关

const progressPercent = ref(0)

const showProgress = ref(false) // 控制进度条显示状态

const updateProgress = () => {

  let currentProgress = 0

  const intervalId = setInterval(() => {

    currentProgress += 10

    progressPercent.value = currentProgress

    if (currentProgress >= 100) {

      clearInterval(intervalId)

      showProgress.value = false // 隐藏进度条

    }

  }, 500)

}

const handleSubmit = async (values: any) => {

  try {

    loading.value = true

    showProgress.value = true // 显示进度条

    // 处理表单数据

    const processedFormData = {

      ...formData,

      startPage: Number(formData.startPage),

      endPage: Number(formData.endPage),

    }

    updateProgress() // 启动进度条

    const res = await uploadPictureByBatchUsingPost(processedFormData)

    if (res.data.code === 0 && res.data.data) {

      const { totalFetched, successCount, failedCount } = res.data.data

      message.success(

        `

        已上传 ${successCount} 张图片

        本次共抓取 ${totalFetched} 张图片

        成功 ${successCount} 张,失败 ${failedCount} 张

      `.trim(),

      )

      router.push('/')

    } else {

      throw new Error(res.data.description)

    }

  } catch (error) {

   showProgress.value = false // todo 加入表单验证逻辑 只有表单全通过才显示进度条 而不是现在的报错就立刻隐藏

    message.error(`创建失败:${(error as Error).message}`)

  } finally {

    loading.value = false

  }

}



#addPictureBatchPage {

  max-width: 720px;

  margin: 40px auto;

  padding: 20px;

  background-color: #f9f9f9;

  border-radius: 8px;

  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);

}

.container {

  text-align: center;

}

h2 {

  font-size: 24px;

  color: #333;

  margin-bottom: 20px;

}

.ant-form-vertical .ant-form-item-label > label {

  font-weight: bold;

  color: #666;

}

.ant-input,

.ant-input-number,

.ant-select-selection {

  border-radius: 4px;

  border: 1px solid #d9d9d9;

  padding: 8px 12px;

  font-size: 14px;

}

.ant-btn-primary {

  background-color: #1890ff;

  border-color: #1890ff;

  color: #fff;

  border-radius: 4px;

  font-size: 14px;

  transition: all 0.3s ease;

  &:hover {

    background-color: #40a9ff;

    border-color: #40a9ff;

  }

}

.ant-progress {

  margin-top: 20px;

}


注意,由于批量抓取任务是同步的,可能比较慢,所以需要添加 loading 效果,防止点击过快重复执行。

定义表单项结构和 loading 变量:

const formData = reactive<API.PictureUploadByBatchRequest>({
  count: 10,
})
const loading = ref(false)

3)编写提交函数,抓取成功后会输出信息并跳转到主页:

const handleSubmit = async (values: any) => {
  loading.value = true;
  const res = await uploadPictureByBatchUsingPost({
    ...formData,
  })
  if (res.data.code === 0 && res.data.data) {
    message.success(`创建成功,共 ${res.data.data} 条`)
    router.push({
      path: '/',
    })
  } else {
    message.error('创建失败,' + res.data.message)
  }
  loading.value = false;
}

测试

批量创建页面效果如图:

可以随意输入关键词进行测试,这下爽了,无论你想做表情包网站、设计素材网站、海报网站、Logo 网站、还是壁纸网站,全都可以轻松完成!

友情提示,虽然本项目在功能上的目标是商业级平台,但一定要注意版权问题,不能随意拿别人的素材来商用。

扩展

1)支持管理员填写每批抓取图片的偏移量,防止重复抓取。

2)系统内部记录原始图片 URL,便于内部复盘归档,但是注意不需要暴露给用户。

3)和批量设置名称一样,支持批量设置抓取到的图片的分类和标签等。

4)我们目前抓取到的图片清晰度有限,可以尝试能否获取到质量更高的图片。

最后

讲到这里,好像我们的公开图库平台功能已经比较完善了,现在就可以上线给别人用了嘛!

如果你也是这个想法,那真的是。。。

太惨啦!

现在上线,得亏死啊!在下一节教程中,鱼皮会带大家做一些重要的优化点,加油学习吧!


项目分区导航:⬅️ 11-消息中心 | 12-用户传图 | ➡️ 13-用户模块