图库项目开发 阶段一-用户传图

发布于:2025-04-11 ⋅ 阅读:(49) ⋅ 点赞:(0)

用户传图

一、用户上传图片及审核

需求分析

1.用户上传图片:需要开放权限,允许用户上传图片,功能与管理员上传图片一样,增加文件校验。

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

业务分析

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

  • 默认为 待审核 - 》 通过/拒绝
  • 拒绝 - 》 通过
  • 通过 - 》 拒绝

2.管理员自动审核:

管理员上传/更新图片时,图片自动审核通过

自动填充审核参数设置审核人为创建人、审核时间为当前时间、审核原因为“管理员自动过审”。

3.用户操作需要审核:

用户上传或编辑图片时,图片的状态会被重置为“待审核”。

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

4.控制内容可见性:

对于用户来说,应该只能看见“审核通过”状态的数据;

管理员可以在图片管理页面看到所有数据,并且根据审核状态筛选图片。

Q:是否要考虑并发问题呢?
A:由于审核操作为管理员手动执行,不涉及复杂的奖励机制或并发高频请求误审核或重复审核对系统影响不大,因此无需过度考虑并发问题。

后端开发

审核状态枚举类

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

管理员审核功能

@Override  get
public void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser) {  
    Long id = pictureReviewRequest.getId();  //GET图片id
    Integer reviewStatus = pictureReviewRequest.getReviewStatus();  //GET图片审核状态 
    PictureReviewStatusEnum reviewStatusEnum = PictureReviewStatusEnum.getEnumByValue(reviewStatus);  // 获取对应枚举
    if (id == null || reviewStatusEnum == null || PictureReviewStatusEnum.REVIEWING.equals(reviewStatusEnum)) {  
        throw new BusinessException(ErrorCode.PARAMS_ERROR);  
    }  // null 判断
    // 判断原图片是否存在  
    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);  
}

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

审核状态设置

1.权限控制

取消图片上传接口的管理员权限限制,改为仅本人/管理 可编辑,补充权限校验逻辑

// 如果是更新图片,需要校验图片是否存在  
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.设置审核状态

管理员图片自动过审并填充审核参数,用户上传/编辑 图片时 审核状态被重置

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

为图片更新/编辑/上传增加审核参数

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

控制内容可见性

目前只有主页能够查看图片列表,需要修改主页调用的接口,补充查询条件,默认只能查看已过审的数据

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

包装类转换方法的补充

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.内容安全审核服务

2.AI审核

3.分级审核策略

4.实名制和内容溯源

5.举报机制

6.审核通知

功能细节

二、URL导入图片

需求分析

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

方案设计

1.图片下载

使用Hutool中的HttpUtil 工具包中的downloadFile方法

2.图片校验

校验文件大小,格式

传统的方法是将文件下载到本地服务器校验。其实可以先对URL本身进行校验。首先是校验URL字符本身的合法性,比如要是一个合理的URL地址,此外可以先使用HEAD请求获取URL对应的元信息。HEAD请求仅返回响应头,不会下载文件。如果是GET会下载文件

3.图片上传

将校验通过的文件上传到对象存储服务,生成存储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();  
        }  
    }  
}

模板优化

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

图片上传支持URL

@PostMapping("/upload")
    public BaseResponse<PictureVO> uploadPicture(
            @RequestPart("file") MultipartFile multipartFile,
            PictureUploadRequest pictureUploadRequest, HttpServletRequest request) {

        User loginUser = userService.getLoginUser(request);
        PictureVO pictureVO = pictureService.uploadPicture(multipartFile, pictureUploadRequest, loginUser);
        return ResultUtils.success(pictureVO);
    }

功能细节

三、批量抓取图片

jsoup

<!-- HTML 解析:https://jsoup.org/ -->  
<dependency>  
    <groupId>org.jsoup</groupId>  
    <artifactId>jsoup</artifactId>  
    <version>1.15.3</version>  
</dependency>

需求分析

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

方案设计

1.如何抓取图片

为了避免版权保护,我们从搜索引擎中搜索图片,仅学习使用、不商用的话几乎没有风险。

从必应中获取图片有2中常见的方法,第一种是请求到完整的页面内容后,对页面内容结构进行解析,提取到图片的地址,再通过URL下载

第二种是直接调用后端获取图片地址的接口,拿到图片数据

这里我们选择第二种,因为第一种可能会出现获取不到图片的情况。

通过jsoup中的选择器定位HTML元素,先通过类选择器找到最外层元素dgControl,再通过CSS选择器img ming找到所有图片元素,导入图片时注意移除图片地址后面的附加参数。

2.抓取和导入规则

搜索规则:关键字

抓取数量:一次不超过30条,bing接口一次能返回的图片数量也是有限的

后端开发

请求体定义

@Data  
public class PictureUploadByBatchRequest {  
  
    /**  
     * 搜索词  
     */  
    private String searchText;  
  
    /**  
     * 抓取数量  
     */  
    private Integer count = 10;  
}

接口开发

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

服务开发(此处合法性验证可以调用urlupload方法中的合法性验证)

    @Override
    public Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {
        String searchText = pictureUploadByBatchRequest.getSearchText();
        String namePrefix = pictureUploadByBatchRequest.getNamePrefix();
        if(StrUtil.isBlank(namePrefix)){
            namePrefix = searchText;
        }
        // 格式化数量
        Integer count = pictureUploadByBatchRequest.getCount();
        ThrowUtils.throwIf(count > 30, ErrorCode.PARAMS_ERROR, "最多 30 条");
        // 构建请求
        int uploadCount = 0;
        String fetchUrl = "https://cn.bing.com/images/async?q=" +
                URLEncoder.encode(searchText, StandardCharsets.UTF_8) +
                "&first=0&count=35&layout=wyimages&mmasync=1";

        try {
            Connection conn = Jsoup.connect(fetchUrl)
                    .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
                    .header("Referer", "https://cn.bing.com/")
                    .timeout(10000);
            Document document = conn.get();
            log.info("成功获取到 HTML 内容,长度: {}", document.html().length());
            // 选择所有 a.iusc 标签
            Elements imgLinks = document.select("a.iusc");
            log.info("找到 {} 个图片链接", imgLinks.size());
            List<String> validUrls = new ArrayList<>();
            ObjectMapper objectMapper = new ObjectMapper();
            for (Element link : imgLinks) {
                String mJson = link.attr("m");
                if (StrUtil.isBlank(mJson) || !mJson.startsWith("{")) {
                    log.warn("无效的 m 属性 JSON: {}", mJson);
                    continue;
                }
                try {
                    JsonNode node = objectMapper.readTree(mJson);
                    if (!node.has("murl") || !node.get("murl").isTextual()) {
                        log.warn("murl 字段缺失或格式错误: {}", mJson);
                        continue;
                    }
                    String imageUrl = node.get("murl").asText();
                    log.info("解析到的原始 URL: {}", imageUrl);
                    //排除64位占位图
                    if (!imageUrl.startsWith("data:image")) {
                        validUrls.add(imageUrl);
                    } else {
                        log.warn("无效的图片格式: {}", imageUrl);
                    }
                } catch (JsonProcessingException e) {
                    log.error("解析 JSON 失败: {}", mJson, e);
                }
            }
            // 上传有效图片
            for (String fileUrl : validUrls) {
                if (uploadCount >= count) {
                    break;
                }
                PictureUploadRequest request = new PictureUploadRequest();
                if (StrUtil.isNotBlank(namePrefix)) {
                    request.setPicName(namePrefix +"-"+ (uploadCount+1));
                }
                try {
                    PictureVO pictureVO = uploadPicture(fileUrl, request, loginUser);
                    log.info("成功上传图片[{}], ID: {}", fileUrl, pictureVO.getId());
                    uploadCount++;
                } catch (Exception e) {
                    log.error("上传图片[{}]失败: {}", fileUrl, e.getMessage());
                }
            }
            log.info("共上传成功{}张图片", uploadCount);
        } catch (IOException e) {
            log.error("请求或解析页面失败", e);
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "请求或解析页面失败");
        }
        return uploadCount;
    }

批量设置属性

PictureUploadByBatchRequest 请求包装类补充参数

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

PictureUploadRequest中补充参数

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

扩展

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

2.支持批量抓取的图片分类和标签

3.如何获取清晰度更高,内容质量更好的图片

功能细节


网站公告

今日签到

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