--- title: "12-用户传图" created: 2025-12-02 tags: - 项目 aliases: - 用户传图 --- # 用户传图 ## 本节重点 之前为了方便和安全性,只支持管理员上传图片,如果想让平台素材更丰富,也要允许用户自主上传图片。 本节我们就重点开发用户传图能力,并支持更多传图的方式,大纲: - 支持用户上传图片和审核功能 - 通过 URL 导入图片 - 批量抓取和创建图片 ## 一、用户上传图片及审核 ### 需求分析 之前我们已经开发了管理员上传图片功能,想实现用户上传图片就比较简单了,但是我们要考虑到一点 “用户上传的内容可能是不安全的”。 一般只要涉及到 “用户上传内容”(俗称 UGC)的场景,就要增加审核功能。 具体分析每个需求: 1)用户上传创建图片:需要开放权限,允许用户上传图片,功能和流程跟之前管理员上传图片一致,也要增加文件校验。 2)管理员审核图片:管理员可以查看和 **筛选** 所有待审核的图片,并标记为通过或拒绝,可填写通过或拒绝的具体原因。此外,需要记录审核人和审核时间作为日志,如果发现误审的情况也可以追责。 ### 方案设计 方案设计阶段我们需要确认: - 审核的具体逻辑 - 库表设计 #### 1、审核逻辑 1)管理员可以操作审核的状态流转: - 默认为 “待审核”,可以设置为 “审核通过” 或 “审核拒绝” - 已拒绝的图片可以重新审核为通过 - 已通过的图片可以撤销为拒绝状态 2)管理员自动审核:管理员上传 / 更新图片时,图片自动审核通过,并且自动填充审核参数 —— 设置审核人为创建人、审核时间为当前时间、审核原因为 “管理员自动过审”。 3)用户操作需要审核:用户上传或编辑图片时,图片的状态会被重置为“待审核”。 重复审核时,既可以选择重置 **所有** 审核参数,也可以仅重置审核状态。其余参数在前端不展示,但是在后端保留,以便管理员参考历史审核信息。 4)控制内容可见性:对于用户来说,应该只能看见 “审核通过” 状态的数据;管理员可以在图片管理页面看到所有数据,并且根据审核状态筛选图片。 Q:是否要考虑并发问题呢? A:由于审核操作为管理员手动执行,不涉及复杂的奖励机制或并发高频请求,误审核或重复审核对系统影响不大,因此无需过度考虑并发问题。 #### 2、库表设计 为了支持审核功能,我们在 picture 图片表中新增审核相关字段,同时优化索引设计以提升查询性能。 修改表的 SQL 如下: ```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 新增: ```java /** * 状态:0-待审核; 1-通过; 2-拒绝 */ private Integer reviewStatus; /** * 审核信息 */ private String reviewMessage; /** * 审核人 id */ private Long reviewerId; /** * 审核时间 */ private Date reviewTime; ``` 2)图片查询请求类 PictureQueryRequest 新增: ```java /** * 状态:0-待审核; 1-通过; 2-拒绝 */ private Integer reviewStatus; /** * 审核信息 */ private String reviewMessage; /** * 审核人 id */ private Long reviewerId; ``` 3)新建审核状态枚举类: ```java @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 字段,这两个是由系统自动填充的,而不是由前端传递。 ```java @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)开发审核服务 接口: ```java /** * 图片审核 * * @param pictureReviewRequest * @param loginUser */ void doPictureReview(PictureReviewRequest pictureReviewRequest, User loginUser); ``` 实现类: ```java @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)开发审核接口,注意权限设置为仅管理员可用: ```java @PostMapping("/review") @PreAuthorizeRole("ADMIN") public BaseResponse 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 方法,补充权限校验逻辑: ```java // 如果是更新图片,需要校验图片是否存在 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 个操作都需要设置审核状态,所以我们可以先编写一个通用的 “补充审核参数” 的方法,根据用户的角色给图片对象填充审核字段的值。 ```java @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 个操作补充审核参数。图片更新接口: ```java public BaseResponse 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); } ``` 图片修改接口: ```java public BaseResponse 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); } ``` 上传图片服务: ```java 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 接口,补充查询条件即可,默认只能查看已过审的数据: ```java // 普通用户默认只能查看已过审的数据 pictureQueryRequest.setReviewStatus(PictureReviewStatusEnum.PASS.getValue()); // 查询数据库 Page picturePage = pictureService.page(new Page<>(current, size), pictureService.getQueryWrapper(pictureQueryRequest)); ``` 需要同步更改 PictureService 的 getQueryWrapper 方法,支持根据审核字段进行查询: ```java 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 常量文件,定义枚举信息、对应的中文映射、以及后续筛选审核状态时要用到的选项数组: ```javascript 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)表格列新增审核信息: ```javascript const columns = [ // ... { title: '审核信息', dataIndex: 'reviewMessage', align: 'center', }, // ... ] ``` 2)自定义审核信息列要展示的内容: ```html ``` 3)新增审核通过和拒绝的操作按钮: ```xml ``` 4)编写审核函数,调用后端完成操作: ```typescript 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、按审核状态筛选 只需要在原来的搜索表单中补充一个表单项即可,使用下拉框组件,传入定义好的审核状态常量作为选项: ```html ``` #### 前端扩展 1)审核通过或拒绝时可以填写原因,可以利用 [模态框组件](https://antdv.com/components/modal-cn) 实现 2)可以在详情页添加审核通过、审核拒绝的快捷操作按钮,仅管理员可见 3)删除和拒绝属于危险操作,可以点击后提示确认框,确认后才执行操作。可以利用 [组件](https://antdv.com/components/popconfirm-cn) 实现 ```java 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 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 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 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 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 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> listPictureByPage( @RequestBody @Valid PictureQueryRequest pictureQueryRequest) { long page = pictureQueryRequest.getPage(); long size = pictureQueryRequest.getPageSize(); ThrowUtils.throwIf(page <= 0 || size <= 0, ErrorCode.PARAMS_ERROR, "分页参数错误"); Page picturePage = pictureService.page(new Page<>(page, size), pictureService.getQueryWrapper(pictureQueryRequest)); return ResultUtils.success(picturePage); } /** * 游标查询获取图片列表(供用户使用) */ @PostMapping("/listByCursor") @PreAuthorizeRole("USER") public BaseResponse listPicturesByCursor( @RequestBody @Valid PictureCursorRequest pictureCursorRequest) { PictureCursorResponse pictureCursorResponse = pictureService.listPicturesByCursor(pictureCursorRequest); return ResultUtils.success(pictureCursorResponse); } /** * 编辑图片(给用户使用) */ @PostMapping("/edit") @PreAuthorizeRole("USER") public BaseResponse 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 listPictureTagCategory() { PictureTagCategory pictureTagCategory = new PictureTagCategory(); List tagList = Arrays.asList("热门", "搞笑", "生活", "高清", "艺术", "校园", "背景", "简历", "创意"); List categoryList = Arrays.asList("模板", "电商", "表情包", "素材", "海报"); pictureTagCategory.setTagList(tagList); pictureTagCategory.setCategoryList(categoryList); return ResultUtils.success(pictureTagCategory); } @PostMapping("/review") @PreAuthorizeRole("ADMIN") public BaseResponse doPictureReview(@RequestBody @Valid PictureReviewRequest pictureReviewRequest, HttpServletRequest request) { ThrowUtils.throwIf(pictureReviewRequest == null, ErrorCode.PARAMS_ERROR, "请求参数不能为空"); LoginUserVO loginUser = userService.getLoginUser(request); pictureService.doPictureReview(pictureReviewRequest, loginUser); return ResultUtils.success(true); } } ``` ### 测试 测试流程: 1. 普通用户上传图片,此时主页看不到这张图片 2. 管理员操作过审,主页就可以看到这张图片 3. 管理员操作拒绝,主页将无法看到这张图片 Q:有同学说,用户现在看不到自己被拒绝的图片啊?怎么修改? A:本项目后续会带大家开发用户个人空间,到时候会完成这个功能。 ### 扩展 #### 1、更多审核策略 在实际企业中,为了提高审核效率、减少垃圾内容,同时保证用户体验和平台的安全性,常常会结合技术手段和业务策略来优化审核流程。比如下面几点,大家可以按需扩展: 1. 内容安全审核服务:借助专业的第三方平台的内容审核服务来实现自动审核,像腾讯云、阿里云等基本都支持图片、文本、音视频等内容的审核。 2. AI 审核:可以将文本内容和审核规则输入给 AI,让 AI 返回是否合规。 3. 分级审核策略:区分普通用户与高信誉用户,高信誉用户可减少或免除审核流程,比如 VIP 用户自动过审,也可以提高部分效率。 4. 实名信息和内容溯源:通过用户实名或者手机号注册,提高用户行为的责任感,减少垃圾内容的产生。 5. 举报机制:通过给平台增加举报机制,还可以给举报行为一些奖励,让用户帮忙维护平台。 #### 2、审核通知 当管理员完成审核后,系统可以通过消息中心或邮件通知用户审核结果。 ## 二、通过 URL 导入图片 ### 需求分析 为了提高上传图片的效率,除了支持上传本地文件外,还可以支持输入一个远程 URL,直接将网上已有的图片导入到我们的系统中。 ### 方案设计 实现原理很简单,但是有一些细节需要注意: 1)下载图片:后端服务器从指定的远程 URL 下载图片到本地临时存储。对于 Java 项目,可以直接使用 Hutool 的 `HttpUtil.downloadFile` 方法一行代码完成。 2)校验图片:跟验证本地文件一样,需要校验图片的格式、大小等。 传统的校验思路是先把文件下载到本地,再对本地文件进行校验,有没有更节省资源的方法呢? 其实可以先对 URL 本身进行校验。首先是校验 URL 字符串本身的合法性,比如要是一个合理的 URL 地址。此外,可以先使用 `HEAD` 请求来获取 URL 对应文件的元信息(如文件大小、格式等)。HEAD 请求仅返回 HTTP 响应头信息,而不会下载文件的内容,大大降低了网络流量的消耗。 > 注意此处不能使用 GET 请求,它会获取完整文件。 3)上传图片:将校验通过的图片上传到对象存储服务,生成存储 URL。 之后的流程就都可以复用从本地上传图片的流程了。 ### 后端开发 #### 1、服务开发 先编写通过 URL 上传文件的方法,为了便于开发,直接在 FileManager 类中编写,绝大多数代码跟之前的 uploadPicture 方法一致,只需要改动以下 4 处位置: 1. 方法接受的参数:之前是 MultipartFile 文件类型,现在是 String 字符串类型 2. 校验图片:之前是校验文件,现在是校验 URL 3. 获取文件名称:之前是根据文件获取,现在是根据 URL 获取 4. 保存临时文件:之前是将 MultipartFile 写入到临时文件,现在是从 URL 下载文件 代码如下: ```java 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 格式、协议、文件是否存在、文件格式、文件大小。 代码如下: ```java 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 ALLOW_CONTENT_TYPES = Arrays.asList("image/jpeg", "image/jpg", "image/png", "image/webp"); ThrowUtils.throwIf(!ALLOW_CONTENT_TYPES.contains(contentType.toLowerCase()), ErrorCode.PARAMS_ERROR, "文件类型错误"); } // 5. 校验文件大小 String contentLengthStr = response.header("Content-Length"); if (StrUtil.isNotBlank(contentLengthStr)) { try { long contentLength = Long.parseLong(contentLengthStr); final long TWO_MB = 2 * 1024 * 1024L; // 限制文件大小为 2MB ThrowUtils.throwIf(contentLength > TWO_MB, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2M"); } catch (NumberFormatException e) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小格式错误"); } } } finally { if (response != null) { response.close(); } } } ``` 上述代码中,注意 2 点: 1. 注意发送 HTTP 请求后,需要即时释放资源 2. 有些 URL 地址可能不支持通过 HEAD 请求访问,为了提高导入成功率,即使 HEAD 请求访问失败,也不会报错,并且不用执行后续的校验。**仅对能获取到的信息进行校验。** #### 3、优化代码 - 模板方法模式 目前我们的 FileManager 文件内写了两种不同的上传文件的方法,但是我们会发现,这两种方法的 **流程完全一致**、而且大多数代码都是相同的。 这种情况下,我们就要想要运用设计模式 —— **模板方法模式** 对代码进行优化。 模板方法模式是行为型设计模式,适用于具有通用处理流程、但处理细节不同的情况。通过定义一个抽象模板类,提供通用的业务流程处理逻辑,并将不同的部分定义为抽象方法,由子类具体实现。 在我们的场景中,两种文件上传方法的流程都是: 1. 校验文件 2. 获取上传地址 3. 获取本地临时文件 4. 上传到对象存储 5. 封装解析得到的图片信息 6. 清理临时文件 可以将这些流程抽象为一套模板(抽象类),将每个实现不一样的步骤都定义为一个抽象方法,比如: 1. 校验图片 2. 获取文件名称 3. 保存临时文件 下面开始开发,先在 `manager` 包下新建 `upload` 包,将模板方法有关的代码全部放在该包下统一管理。 1)新建图片上传模板 **抽象类** PictureUploadTemplate,代码如下: ```java 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 实例: ```java 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 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 实例: ```java 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 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 ```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 ```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 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 ```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 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。 ```text /** * 文件服务 * @deprecated 已废弃,改为使用 upload 包的模板方法优化 */ @Deprecated ``` #### 4、图片上传服务支持 URL 上传 由于图片上传的逻辑还是比较复杂的,尽量让 URL 上传复用之前的代码。 但是之前图片上传服务的 uploadPicture 方法接受的是文件类型的参数,现在要支持 URL 上传,怎么办呢? 可以将输入参数跟上述模板一样,改为 Object 类型的 inputSource,然后在代码中可以根据 inputSource 的实际类型,来选择对应的图片上传子类。代码如下: ```java @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 文件地址: ```java 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 上传图片: ```java /** * 通过 URL 上传图片(可重新上传) */ @PostMapping("/uploadByUrl") @PreAuthorizeRole("USER") public BaseResponse 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 上传组件 = 文本输入框 + 提交按钮 可以使用组件库的 [复合输入框组件](https://antdv.com/components/input-cn#components-input-demo-group): ```html
提交 avatar
``` 开发上传操作函数,需要将用户输入的 fileUrl 提交到后端: ```typescript ``` #### 2、开发创建页面 之前已经开发了创建图片页面,可以在上传图片时增加一个 [Tabs 选项组件](https://antdv.com/components/tabs-cn),让用户自己选择上传方式。 ```javascript ``` 定义上传类型变量: ```javascript const uploadType = ref<'file' | 'url'>('file') ``` 其他代码都不需要调整。你会发现只要开发思路清晰、代码结构良好,新功能的扩展是很快的 ## 批量抓取和创建图片 ### 需求分析 为了帮助管理员快速丰富图片库,冷启动项目,需要提供批量从网络抓取并创建图片的功能。 但是要注意,不建议将该功能开放给普通用户!主要是为了防止滥用导致的版权问题、低质量内容的上传、服务器资源消耗和安全问题。因为我们要从网络批量抓取图片(爬虫),如果功能开放给用户,相当于所有用户都在使用我们的服务器作为爬虫源头,容易导致我们的服务器 IP 被封禁。 ### 方案设计 方案设计的重点包括: - 如何抓取图片 - 抓取和导入规则 #### 1、如何抓取图片? 思考 2 个问题:从哪里抓取图片?怎么抓取图片呢? 绝大多数的图片素材网站,都是有版权保护的,不建议大家操作,容易被封禁 IP 和账号。比较安全的方法是从搜索引擎中抓取图片,仅学习使用、不商用的话基本不会有什么风险。 这里我们选择从 bing 搜索获取图片,首先进入 [bing 图片网站](https://cn.bing.com/images),可以看到很多图片,但是如何获取这些图片呢? ![[bibULWXkn8YRera2-d408069c.webp]] 有 2 种常见的做法,第一种是请求到完整的页面内容后,对页面的 HTML 结构进行解析,提取到图片的地址,再通过 URL 下载;还有一种是直接调用后端获取图片地址的接口拿到图片数据。 要使用哪种方式,还是要具体情况具体分析,比如在调研过程中,我们会发现直接从 bing 图片的首页抓取数据,可能会出现获取不到图片的情况。所以我们换一种策略,尝试去找图片接口。 按 F12 打开网络请求控制台,向下滚动图片时会触发新一波图片的加载,就能看到获取图片数据的接口了:[https://cn.bing.com/images/async?q=%s&mmasync=1](https://cn.bing.com/images/async?q=%25s&mmasync=1) > 注意,URL 地址必须要添加 mmasync=1 参数!否则加载条数不对 ![[OjWxSydd2AUZFfzg-cbc931d0.webp]] ![[uiThuLEhEhDSF3jE-cbc931d0.webp]] 但是该接口返回的还是 HTML 文档结构,所以我们需要使用一个 HTML 文档解析库来提取图片地址,Java 中比较推荐 [jsoup](https://jsoup.org/),非常地轻量。 jsoup 支持使用跟前端一致的选择器语法来定位 HTML 的元素,比如类选择器、CSS 选择器。我们可以先通过类选择器找到最外层的元素 `dgControl`,再通过 CSS 选择器 `img.mimg` 找到所有的图片元素: ![[xwQsxcdEPhi6yL94-ae37b78b.webp]] 注意,图片的地址后面有很多附加参数,比如 `?w=199&h=180`,在导入图片时一定要移除!否则会影响图片的质量,还有可能导致上传到对象存储的文件包含被转义的特殊字符,引发无法访问等问题。 #### 2、抓取和导入规则 可以在抓取时,让管理员填写以下参数: - 搜索关键词:便于找到需要的数据 - 抓取数量:单次要抓取的条数,不建议超过 30 条(接口单次返回的图片有限) ### 后端开发 #### 1、定义请求体 在 `model.dto.picture` 包下新建 PictureUploadByBatchRequest: ```java 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 库](https://jsoup.org/),此处选 v1.15.3 版本,使用的人较多: ```xml org.jsoup jsoup 1.15.3 ``` 2)编写批量抓取和创建图片方法 接口: ```java /** * 批量抓取和创建图片 * * @param pictureUploadByBatchRequest 图片批量上传请求 * @param loginUser 登入用户 * @return 抓取详情 */ PictureUploadByBatchResponse uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, LoginUserVO loginUser); ``` 实现类: ```java @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; } ``` ```java /** * 简化逻辑 * 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 allImageUrls = new ArrayList<>(); List 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>> futures = new ArrayList<>(); for (String fileUrl : allImageUrls) { final String finalFileUrl = fileUrl; Future> 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> future : futures) { try { Pair 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 中新增接口,注意限制仅管理员可用: ```java /** * 批量抓取和创建图片 */ @PostMapping("/uploadByBatch") @PreAuthorizeRole("ADMIN") public BaseResponse 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 参数: ```java 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 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,补充图片名称生成逻辑: ```java /** * 简化逻辑 * 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 allImageUrls = new ArrayList<>(); List 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>> futures = new ArrayList<>(); // 计数器(初始值为1) AtomicInteger sequence = new AtomicInteger(1); for (String fileUrl : allImageUrls) { final String finalFileUrl = fileUrl; Future> 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> future : futures) { try { Pair 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、图片管理页面补充按钮 管理页面补充 “批量创建图片” 按钮,代码如下: ```html + 创建图片 + 批量创建图片 ``` #### 2、批量创建图片页面 1)新建页面文件 `AddPictureBatchPage.vue`(复制创建图片页面),并添加路由: ```java { path: '/add_picture/batch', name: '批量创建图片', component: AddPictureBatchPage, } ``` 正常情况下,普通用户是看不见该页面的,即使看见了,也会因为后端的限制无法使用。 2)该页面主体是一个表单,和创建图片页面极为相似,先修改表单项: ```java ``` 注意,由于批量抓取任务是同步的,可能比较慢,所以需要添加 loading 效果,防止点击过快重复执行。 定义表单项结构和 loading 变量: ```javascript const formData = reactive({ count: 10, }) const loading = ref(false) ``` 3)编写提交函数,抓取成功后会输出信息并跳转到主页: ```javascript const handleSubmit = async (values: any) => { loading.value = true; const res = await uploadPictureByBatchUsingPost({ ...formData, }) if (res.data.code === 0 && res.data.data) { message.success(`创建成功,共 ${res.data.data} 条`) router.push({ path: '/', }) } else { message.error('创建失败,' + res.data.message) } loading.value = false; } ``` ### 测试 批量创建页面效果如图: 可以随意输入关键词进行测试,这下爽了,无论你想做表情包网站、设计素材网站、海报网站、Logo 网站、还是壁纸网站,全都可以轻松完成! **友情提示,虽然本项目在功能上的目标是商业级平台,但一定要注意版权问题,不能随意拿别人的素材来商用。** ### 扩展 1)支持管理员填写每批抓取图片的偏移量,防止重复抓取。 2)系统内部记录原始图片 URL,便于内部复盘归档,但是注意不需要暴露给用户。 3)和批量设置名称一样,支持批量设置抓取到的图片的分类和标签等。 4)我们目前抓取到的图片清晰度有限,可以尝试能否获取到质量更高的图片。 ## 最后 讲到这里,好像我们的公开图库平台功能已经比较完善了,现在就可以上线给别人用了嘛! 如果你也是这个想法,那真的是。。。 **太惨啦!** 现在上线,得亏死啊!在下一节教程中,鱼皮会带大家做一些重要的优化点,加油学习吧! --- **项目分区导航**:⬅️ [[11-消息中心|11-消息中心]] | 12-用户传图 | ➡️ [[13-用户模块|13-用户模块]]