图片上传

tim-qtp...大约 10 分钟云图库项目

1、使用腾讯云对象存储(COS)

1)引入 COS 依赖:

<!-- 腾讯云 cos 服务 -->  
<dependency>  
    <groupId>com.qcloud</groupId>  
    <artifactId>cos_api</artifactId>  
    <version>5.6.227</version>  
</dependency>

2)在项目的 config 包下新建 CosClientConfig 类。负责读取配置文件,并创建一个 COS 客户端的 Bean。代码如下:

@Configuration  
@ConfigurationProperties(prefix = "cos.client")  
@Data  
public class CosClientConfig {  
  
    /**  
     * 域名  
     */  
    private String host;  
  
    /**  
     * secretId  
     */  
    private String secretId;  
  
    /**  
     * 密钥(注意不要泄露)  
     */  
    private String secretKey;  
  
    /**  
     * 区域  
     */  
    private String region;  
  
    /**  
     * 桶名  
     */  
    private String bucket;  
  
    @Bean  
    public COSClient cosClient() {  
        // 初始化用户身份信息(secretId, secretKey)  
        COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);  
        // 设置bucket的区域, COS地域的简称请参照 https://www.qcloud.com/document/product/436/6224  
        ClientConfig clientConfig = new ClientConfig(new Region(region));  
        // 生成cos客户端  
        return new COSClient(cred, clientConfig);  
    }  

3)填写配置文件。

一定要注意防止密码泄露! 所以我们新建 application-local.yml 文件,并且在 .gitignore 中忽略该文件的提交,这样就不会将代码等敏感配置提交到代码仓库了。

image-20250318215046319

application-local.yml 配置代码如下:

# 对象存储配置(需要从腾讯云获取)  
cos:  
  client:  
    host: xxx  
    secretId: xxx  
    secretKey: xxx  
    region: xxx  
    bucket: xxx

可以通过如下方式分别获取需要的配置。

host 为存储桶域名,可以在 COS 控制台的域名信息部分找到:

secretId、secretKey 密钥对:在腾讯云访问管理open in new window => 密钥管理中获取。

region 表示地域名,可以 点此获取open in new window。3n/Enp4sfaVRq/PFMJKPxPdBTxeNas/Bp33wsl/l9Wg=

bucket 是存储桶名,可以点进存储桶详情页获取

2、通用能力类

manager 包下新建 CosManager 类,提供通用的对象存储操作,比如文件上传、文件下载等。

💡 Manager 也是人为约定的一种写法,表示通用的、可复用的能力,可供其他代码(比如 Service)调用。

该类需要引入对象存储配置和 COS 客户端,用于和 COS 进行交互。代码如下:

@Component  
public class CosManager {  
  
    @Resource  
    private CosClientConfig cosClientConfig;  
  
    @Resource  
    private COSClient cosClient;  
  
    // ... 一些操作 COS 的方法  
}

3、文件上传下载

参考 官方文档open in new window 的“上传对象”部分,可以编写出文件上传的代码。

1)CosManager 新增上传、下载对象的方法,代码如下:

/**  
 * 上传对象  
 *  
 * @param key  唯一键  
 * @param file 文件  
 */  
public PutObjectResult putObject(String key, File file) {  
    PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key,  
            file);  
    return cosClient.putObject(putObjectRequest);  
}

/**
 * 下载对象
 *
 * @param key 唯一键
*/
public COSObject getObject(String key) {
    GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
    return cosClient.getObject(getObjectRequest);
}

2)为了方便测试,在 FileController 中编写测试文件上传接口。

核心流程是先接受用户上传的文件,指定上传的路径,然后调用 cosManager.putObject 方法上传文件到 COS 对象存储;上传成功后,会返回一个文件的 key(其实就是文件路径),便于我们访问和下载文件。

需要注意,测试接口一定要加上管理员权限!防止任何用户随意上传文件。

测试文件上传接口代码如下:

/**  
 * 测试文件上传  
 *  
 * @param multipartFile  
 * @return  
 */  
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)  
@PostMapping("/test/upload")  
public BaseResponse<String> testUploadFile(@RequestPart("file") MultipartFile multipartFile) {  
    // 文件目录  
    String filename = multipartFile.getOriginalFilename();  
    String filepath = String.format("/test/%s", filename);  
    File file = null;  
    try {  
        // 上传文件  
        file = File.createTempFile(filepath, null);  
        multipartFile.transferTo(file);  
        cosManager.putObject(filepath, file);  
        // 返回可访问地址  
        return ResultUtils.success(filepath);  
    } catch (Exception e) {  
        log.error("file upload error, filepath = " + filepath, e);  
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");  
    } finally {  
        if (file != null) {  
            // 删除临时文件  
            boolean delete = file.delete();  
            if (!delete) {  
                log.error("file delete error, filepath = {}", filepath);  
            }  
        }  
    }  
}

/**
     * 测试文件下载
     *
     * @param filepath 文件路径
     * @param response 响应对象
     */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@GetMapping("/test/download/")
public void testDownloadFile(String filepath, HttpServletResponse response) throws IOException {
    COSObjectInputStream cosObjectInput = null; //流是要关闭的,所以得提出来
    try {
        COSObject cosObject = cosManager.getObject(filepath);
        cosObjectInput = cosObject.getObjectContent(); //得到实际的内容
        byte[] bytes = IOUtils.toByteArray(cosObjectInput); //转换为字节
        // 设置响应头,传到前端,前端就知道我要下载图片了
        response.setContentType("application/octet-stream;charset=UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename=" + filepath);
        // 写入响应
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();
    } catch (Exception e) {
        log.error("file download error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
    } finally {
        // 释放流
        if (cosObjectInput != null) {
            cosObjectInput.close();
        }
    }
}    

4、图片上传

1、数据模型

model.dto.picture 下新建用于接受请求参数的类。由于图片需要支持重复上传(基础信息不变,只改变图片文件),所以要添加图片 id 参数:

@Data  
public class PictureUploadRequest implements Serializable {  
  
    /**  
     * 图片 id(用于修改)  
     */  
    private Long id;  
  
    private static final long serialVersionUID = 1L;  
}

model.vo 下新建上传成功后返回给前端的响应类,这是一个视图包装类,可以额外关联上传图片的用户信息。还可以编写 Picture 实体类和该 VO 类的转换方法,便于后续快速传值。

@Data  
public class PictureVO implements Serializable {  
  
    /**  
     * id  
     */  
    private Long id;  
  
    /**  
     * 图片 url  
     */  
    private String url;  
  
    /**  
     * 图片名称  
     */  
    private String name;  
  
    /**  
     * 简介  
     */  
    private String introduction;  
  
    /**  
     * 标签  
     */  
    private List<String> tags;  
  
    /**  
     * 分类  
     */  
    private String category;  
  
    /**  
     * 文件体积  
     */  
    private Long picSize;  
  
    /**  
     * 图片宽度  
     */  
    private Integer picWidth;  
  
    /**  
     * 图片高度  
     */  
    private Integer picHeight;  
  
    /**  
     * 图片比例  
     */  
    private Double picScale;  
  
    /**  
     * 图片格式  
     */  
    private String picFormat;  
  
    /**  
     * 用户 id  
     */  
    private Long userId;  
  
    /**  
     * 创建时间  
     */  
    private Date createTime;  
  
    /**  
     * 编辑时间  
     */  
    private Date editTime;  
  
    /**  
     * 更新时间  
     */  
    private Date updateTime;  
  
    /**  
     * 创建用户信息  
     */  
    private UserVO user;  
  
    private static final long serialVersionUID = 1L;  
  
    /**  
     * 封装类转对象  
     */  
    public static Picture voToObj(PictureVO pictureVO) {  
        if (pictureVO == null) {  
            return null;  
        }  
        Picture picture = new Picture();  
        BeanUtils.copyProperties(pictureVO, picture);  
        // 类型不同,需要转换(json转str)
        picture.setTags(JSONUtil.toJsonStr(pictureVO.getTags()));  
        return picture;  
    }  
  
    /**  
     * 对象转封装类  
     */  
    public static PictureVO objToVo(Picture picture) {  
        if (picture == null) {  
            return null;  
        }  
        PictureVO pictureVO = new PictureVO();  
        BeanUtils.copyProperties(picture, pictureVO);  
        // 类型不同,需要转换(str转json)
        pictureVO.setTags(JSONUtil.toList(picture.getTags(), String.class));  
        return pictureVO;  
    }  
}

2、通用文件上传服务

之前虽然我们已经编写了通用的对象存储操作类 CosManager,但这个类并不能直接满足我们的图片上传需求。

比如:

  • 图片是否符合要求?需要校验
  • 将图片上传到哪里?需要指定路径
  • 如何解析图片?需要使用数据万象服务

1)在 model.dto.file 中新增用于接受图片解析信息的包装类:

▼java复制代码@Data  
public class UploadPictureResult {  
  
    /**  
     * 图片地址  
     */  
    private String url;  
  
    /**  
     * 图片名称  
     */  
    private String picName;  
  
    /**  
     * 文件体积  
     */  
    private Long picSize;  
  
    /**  
     * 图片宽度  
     */  
    private int picWidth;  
  
    /**  
     * 图片高度  
     */  
    private int picHeight;  
  
    /**  
     * 图片宽高比  
     */  
    private Double picScale;  
  
    /**  
     * 图片格式  
     */  
    private String picFormat;  
  
}

2)参考 数据万象open in new window 的文档,在 CosManager 中添加上传图片并解析图片的方法:

如果你之前没有使用过数据万象,需要先 开通数据万象并授权open in new window,否则会报错:

3)在 FileManager 中编写上传图片的方法:

/**  
 * 上传图片  
 *  
 * @param multipartFile    文件  
 * @param uploadPathPrefix 上传路径前缀  
 * @return  
 */  
public UploadPictureResult uploadPicture(MultipartFile multipartFile, String uploadPathPrefix) {  
    // 校验图片  
    validPicture(multipartFile);  
    // 图片上传地址  
    String uuid = RandomUtil.randomString(16);  
    String originFilename = multipartFile.getOriginalFilename();  
    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);  
        // 上传图片  
        PutObjectResult putObjectResult = cosManager.putPictureObject(uploadPath, file);  
        ImageInfo imageInfo = putObjectResult.getCiUploadResult().getOriginalInfo().getImageInfo();  
        // 封装返回结果  
        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;  
    } catch (Exception e) {  
        log.error("图片上传到对象存储失败", e);  
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");  
    } finally {  
        this.deleteTempFile(file);  
    }  
}  
  
/**  
 * 校验文件  
 *  
 * @param multipartFile multipart 文件  
 */  
public void validPicture(MultipartFile multipartFile) {  
    ThrowUtils.throwIf(multipartFile == null, ErrorCode.PARAMS_ERROR, "文件不能为空");  
    // 1. 校验文件大小  
    long fileSize = multipartFile.getSize();  
    final long ONE_M = 1024 * 1024L;  
    ThrowUtils.throwIf(fileSize > 2 * ONE_M, ErrorCode.PARAMS_ERROR, "文件大小不能超过 2M");  
    // 2. 校验文件后缀  
    String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());  
    // 允许上传的文件后缀  
    final List<String> ALLOW_FORMAT_LIST = Arrays.asList("jpeg", "jpg", "png", "webp");  
    ThrowUtils.throwIf(!ALLOW_FORMAT_LIST.contains(fileSuffix), ErrorCode.PARAMS_ERROR, "文件类型错误");  
}  
  
/**  
 * 删除临时文件  
 */  
public void deleteTempFile(File file) {  
    if (file == null) {  
        return;  
    }  
    // 删除临时文件  
    boolean deleteResult = file.delete();  
    if (!deleteResult) {  
        log.error("file delete error, filepath = {}", file.getAbsolutePath());  
    }  
}

上述代码中有几个实现关键:

  1. 由于文件校验规则较复杂,单独抽象为 validPicture 方法,对文件大小、类型进行校验。
  2. 文件上传时,会先在本地创建临时文件,无论上传是否成功,都要记得删除临时文件,否则会导致资源泄露。
  3. 可以根据自己的需求定义文件上传地址,比如此处鱼皮给文件名前增加了上传日期和 16 位 uuid 随机数,便于了解文件上传时间并防止文件重复。还预留了一个 uploadPathPrefix 参数,由调用方指定上传文件到哪个目录。

💡 如果多个项目共享存储桶,可以给上传文件路径再加一个 ProjectName 前缀。不过建议还是每个项目独立分配资源。

3、服务开发

在 PictureService 中编写上传图片的方法:

接口:

/**  
 * 上传图片  
 *  
 * @param multipartFile  
 * @param pictureUploadRequest  
 * @param loginUser  
 * @return  
 */  
PictureVO uploadPicture(MultipartFile multipartFile,  
                        PictureUploadRequest pictureUploadRequest,  
                        User loginUser);

实现类:

@Override  
public PictureVO uploadPicture(MultipartFile multipartFile, PictureUploadRequest pictureUploadRequest, User loginUser) {  
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);  
    // 用于判断是新增还是更新图片  
    Long pictureId = null;  
    if (pictureUploadRequest != null) {  
        pictureId = pictureUploadRequest.getId();  
    }  
    // 如果是更新图片,需要校验图片是否存在  
    if (pictureId != null) {  
        boolean exists = this.lambdaQuery()  
                .eq(Picture::getId, pictureId)  
                .exists();  
        ThrowUtils.throwIf(!exists, ErrorCode.NOT_FOUND_ERROR, "图片不存在");  
    }  
    // 上传图片,得到信息  
    // 按照用户 id 划分目录  
    String uploadPathPrefix = String.format("public/%s", loginUser.getId());  
    UploadPictureResult uploadPictureResult = fileManager.uploadPicture(multipartFile, uploadPathPrefix);  
    // 构造要入库的图片信息  
    Picture picture = new Picture();  
    picture.setUrl(uploadPictureResult.getUrl());  
    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());  
    // 如果 pictureId 不为空,表示更新,否则是新增  
    if (pictureId != null) {  
        // 如果是更新,需要补充 id 和编辑时间  
        picture.setId(pictureId);  
        picture.setEditTime(new Date());  
    }  
    boolean result = this.saveOrUpdate(picture);  
    ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片上传失败");  
    return PictureVO.objToVo(picture);  
}

上述代码中,注意:

  1. 我们将所有图片都放到了 public 目录下,并且每个用户的图片存储到对应用户 id 的目录下,便于管理。
  2. 如果 pictureId 不为空,表示更新已有图片的信息,需要判断对应 id 的图片是否存在,并且更新时要指定 editTime 编辑时间。可以调用 MyBatis Plus 提供的 saveOrUpdate 方法兼容创建和更新操作。

4、接口开发

在 PictureController 中编写上传图片接口,注意仅管理员可用:

/**  
 * 上传图片(可重新上传)  
 */  
@PostMapping("/upload")  
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)  
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);  
}

5、测试

使用 Swagger 进行测试,发现当上传图片过大时,会触发一段报错。但这个报错不是我们自定义的异常导致的,而是由于 Tomcat 服务器默认限制了请求中文件上传的大小。

需要在 application.yml 中更改配置,调大允许上传的文件大小:

spring:  
  # 开放更大的文件上传体积  
  servlet:  
    multipart:  
      max-file-size: 10MB

扩展思路

1)可以用枚举类(FileUploadBizEnum)支持根据业务场景区分文件上传路径、校验规则等,从而复用 FileManager。

2)目前在文件上传时,会先在本地创建临时文件。如果你不需要对文件进行额外的处理、想进一步提高性能,可以直接用流的方式将请求中的文件上传到 COS。以下代码仅供参考:

// 上传文件  
public static String uploadToCOS(MultipartFile multipartFile, String bucketName, String key) throws Exception {  
    // 创建 COS 客户端  
    COSClient cosClient = createCOSClient();  
  
    try (InputStream inputStream = multipartFile.getInputStream()) {  
        // 元信息配置  
        ObjectMetadata metadata = new ObjectMetadata();  
        metadata.setContentLength(multipartFile.getSize());  
        metadata.setContentType(multipartFile.getContentType());  
  
        // 创建上传请求  
        PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, inputStream, metadata);  
  
        // 上传文件  
        cosClient.putObject(putObjectRequest);  
  
        // 生成访问链接  
        return "https://" + bucketName + ".cos." + cosClient.getClientConfig().getRegion().getRegionName()  
               + ".myqcloud.com/" + key;  
    } finally {  
        cosClient.shutdown();  
    }  
}

3)补充更严格的校验,比如为支持的图片格式定义枚举,仅允许上传枚举定义的格式。

图片管理

图片管理功能具体可以拆分为:

  • 【管理员】根据 id 删除图片
  • 【管理员】更新图片
  • 【管理员】分页获取图片列表(不需要脱敏和限制条数)
  • 【管理员】根据 id 获取图片(不需要脱敏)
  • 分页获取图片列表(需要脱敏和限制条数)
  • 根据 id 获取图片(需要脱敏)
  • 修改图片

1、数据模型

每个操作都需要提供一个请求类,都放在 model.dto.picture 包下。