文章目录
- 用户传图
- 一、用户上传图片及审核
- 需求分析
- 业务分析
- 后端开发
- 管理员审核功能
- 审核状态设置
- 控制内容可见性
- 扩展
- 功能细节
- 二、URL导入图片
- 需求分析
- 方案设计
- 后端开发
- URL校验
- 模板优化
- 图片上传支持URL
- 功能细节
- 三、批量抓取图片
- 需求分析
- 方案设计
- 1.如何抓取图片
- 2.抓取和导入规则
- 后端开发
- 请求体定义
- 接口开发
- 服务开发(此处合法性验证可以调用urlupload方法中的合法性验证)
- 批量设置属性
- 扩展
- 功能细节
用户传图
一、用户上传图片及审核
需求分析
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图片idInteger 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方法中的合法性验证)
@Overridepublic 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.如何获取清晰度更高,内容质量更好的图片