用户传图
本节重点
之前为了方便和安全性,只支持管理员上传图片,如果想让平台素材更丰富,也要允许用户自主上传图片。
本节我们就重点开发用户传图能力,并支持更多传图的方式,大纲:
- 支持用户上传图片和审核功能
- 通过 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);
}
}
测试
测试流程:
- 普通用户上传图片,此时主页看不到这张图片
- 管理员操作过审,主页就可以看到这张图片
- 管理员操作拒绝,主页将无法看到这张图片
Q:有同学说,用户现在看不到自己被拒绝的图片啊?怎么修改?
A:本项目后续会带大家开发用户个人空间,到时候会完成这个功能。
扩展
1、更多审核策略
在实际企业中,为了提高审核效率、减少垃圾内容,同时保证用户体验和平台的安全性,常常会结合技术手段和业务策略来优化审核流程。比如下面几点,大家可以按需扩展:
- 内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。
- AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。
- 分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。
- 实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。
- 举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。
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 处位置:
- 方法接受的参数:之前是 MultipartFile 文件类型,现在是 String 字符串类型
- 校验图片:之前是校验文件,现在是校验 URL
- 获取文件名称:之前是根据文件获取,现在是根据 URL 获取
- 保存临时文件:之前是将 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 点:
- 注意发送 HTTP 请求后,需要即时释放资源
- 有些 URL 地址可能不支持通过 HEAD 请求访问,为了提高导入成功率,即使 HEAD 请求访问失败,也不会报错,并且不用执行后续的校验。仅对能获取到的信息进行校验。
3、优化代码 - 模板方法模式
目前我们的 FileManager 文件内写了两种不同的上传文件的方法,但是我们会发现,这两种方法的 流程完全一致、而且大多数代码都是相同的。
这种情况下,我们就要想要运用设计模式 —— 模板方法模式 对代码进行优化。
模板方法模式是行为型设计模式,适用于具有通用处理流程、但处理细节不同的情况。通过定义一个抽象模板类,提供通用的业务流程处理逻辑,并将不同的部分定义为抽象方法,由子类具体实现。
在我们的场景中,两种文件上传方法的流程都是:
- 校验文件
- 获取上传地址
- 获取本地临时文件
- 上传到对象存储
- 封装解析得到的图片信息
- 清理临时文件
可以将这些流程抽象为一套模板(抽象类),将每个实现不一样的步骤都定义为一个抽象方法,比如:
- 校验图片
- 获取文件名称
- 保存临时文件
下面开始开发,先在 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 图片网站,可以看到很多图片,但是如何获取这些图片呢?
有 2 种常见的做法,第一种是请求到完整的页面内容后,对页面的 HTML 结构进行解析,提取到图片的地址,再通过 URL 下载;还有一种是直接调用后端获取图片地址的接口拿到图片数据。
要使用哪种方式,还是要具体情况具体分析,比如在调研过程中,我们会发现直接从 bing 图片的首页抓取数据,可能会出现获取不到图片的情况。所以我们换一种策略,尝试去找图片接口。
按 F12 打开网络请求控制台,向下滚动图片时会触发新一波图片的加载,就能看到获取图片数据的接口了:https://cn.bing.com/images/async?q=%s&mmasync=1
注意,URL 地址必须要添加 mmasync=1 参数!否则加载条数不对
但是该接口返回的还是 HTML 文档结构,所以我们需要使用一个 HTML 文档解析库来提取图片地址,Java 中比较推荐 jsoup,非常地轻量。
jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。我们可以先通过类选择器找到最外层的元素 dgControl,再通过
CSS 选择器 img.mimg 找到所有的图片元素:
注意,图片的地址后面有很多附加参数,比如 ?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)我们目前抓取到的图片清晰度有限,可以尝试能否获取到质量更高的图片。
最后
讲到这里,好像我们的公开图库平台功能已经比较完善了,现在就可以上线给别人用了嘛!
如果你也是这个想法,那真的是。。。
太惨啦!
现在上线,得亏死啊!在下一节教程中,鱼皮会带大家做一些重要的优化点,加油学习吧!
💬 评论