文章目录
用户传图
一、用户上传图片及审核
需求分析
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.如何获取清晰度更高,内容质量更好的图片