背景说明
在 智能协同云图库项目 中,我们要支持两种上传图片的方式,①本地上传,②通过远程 URL 上传。
本地上传图片
新增接受图片解析信息的包装类
@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;
}
在 CosManager 中添加上传图片并解析图片的方法
/**
* 上传对象(附带图片信息)
*
* @param key 唯一键
* @param file 文件
*/
public PutObjectResult putPictureObject(String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key,
file);
// 对图片进行处理(获取基本信息也被视作为一种处理)
PicOperations picOperations = new PicOperations();
// 1 表示返回原图信息
picOperations.setIsPicInfo(1);
// 构造处理参数
putObjectRequest.setPicOperations(picOperations);
return cosClient.putObject(putObjectRequest);
}
在 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());
}
}
注意:在文件上传时,要先在本地创建临时文件,无论是否上传成功,都要删除,不然容易导致资源泄露
Service接口编写
/**
* 上传图片
*
* @param multipartFile
* @param pictureUploadRequest
* @param loginUser
* @return
*/
PictureVO uploadPicture(MultipartFile multipartFile,
PictureUploadRequest pictureUploadRequest,
User loginUser);
Service层实现类编写
@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);
}
Controller层接口开发
/**
* 上传图片(可重新上传)
*/
@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);
}
通过Url上传图片
和之前 本地上传图片方法 一样,只需要改动 4 处
- 方法接受参数:之前是 MultipartFile,现在是 String 字符串类型。
- 校验:之前是校验文件,现在是校验 Url。
- 获取文件名:之前是根据文件获取,现在是根据 Url 获取。
- 保存临时文件:之前是将 MultipartFile 写入临时文件,现在是从 Url 下载文件
public UploadPictureResult uploadPictureByUrl(String fileUrl, String uploadPathPrefix) {
// 校验图片
// validPicture(multipartFile);
validPicture(fileUrl);
// 图片上传地址
String uuid = RandomUtil.randomString(16);
// String originFilename = multipartFile.getOriginalFilename();
String originFilename = FileUtil.mainName(fileUrl);
String uploadFilename = String.format("%s_%s.%s", DateUtil.formatDate(new Date()), uuid,
FileUtil.getSuffix(originFilename));
String uploadPath = String.format("/%s/%s", uploadPathPrefix, uploadFilename);
File file = null;
try {
// 创建临时文件
file = File.createTempFile(uploadPath, null);
// multipartFile.transferTo(file);
HttpUtil.downloadFile(fileUrl, file);
// 上传图片
// ... 其余代码保持不变
} catch (Exception e) {
log.error("图片上传到对象存储失败", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
} finally {
this.deleteTempFile(file);
}
}
校验 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();
}
}
}
模板方法优化
两种上传方法 流程完全一直,只是具体方法有差别。
两种文件上传上传发放的流程都是:
- 校验文件
- 获取上传地址
- 获取本地临时文件
- 上传到对象存储
- 封装解析得到的图片信息
- 清理临时文件
可以将这些流程抽象为一套模板,将不一样的实现步骤都定义为一个抽象方法
- 校验图片
- 获取文件名称
- 保存临时文件
新建一个图片上传模板抽象类 PictureUploadTemplate
@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());
}
}
}
为了让模板同时兼容 MultipartFile 和 String 类型的文件参数,将这两种情况同一定义为 Object 类型的 inputSource 输入源。
新建本地图片上传子类
@Service
public class FilePictureUpload extends PictureUploadTemplate {
@Override
protected void validPicture(Object inputSource) {
MultipartFile multipartFile = (MultipartFile) inputSource;
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, "文件类型错误");
}
@Override
protected String getOriginFilename(Object inputSource) {
MultipartFile multipartFile = (MultipartFile) inputSource;
return multipartFile.getOriginalFilename();
}
@Override
protected void processFile(Object inputSource, File file) throws Exception {
MultipartFile multipartFile = (MultipartFile) inputSource;
multipartFile.transferTo(file);
}
}
新建 Url 图片上传子类,继承模板
@Service
public class UrlPictureUpload extends PictureUploadTemplate {
@Override
protected void validPicture(Object inputSource) {
String fileUrl = (String) inputSource;
ThrowUtils.throwIf(StrUtil.isBlank(fileUrl), ErrorCode.PARAMS_ERROR, "文件地址不能为空");
// ... 跟之前的校验逻辑保持一致
}
@Override
protected String getOriginFilename(Object inputSource) {
String fileUrl = (String) inputSource;
// 从 URL 中提取文件名
return FileUtil.mainName(fileUrl);
}
@Override
protected void processFile(Object inputSource, File file) throws Exception {
String fileUrl = (String) inputSource;
// 下载文件到临时目录
HttpUtil.downloadFile(fileUrl, file);
}
}
通过自动解析,识别是本地文件上传还是 Url 上传。
@Resource
private FilePictureUpload filePictureUpload;
@Resource
private UrlPictureUpload urlPictureUpload;
// 上传图片
public PictureVO uploadPicture(Object inputSource, PictureUploadRequest pictureUploadRequest, User loginUser) {
if (inputSource == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "图片为空");
}
// ...
// 按照用户 id 划分目录
String uploadPathPrefix = String.format("public/%s", loginUser.getId());
// 根据 inputSource 类型区分上传方式
PictureUploadTemplate pictureUploadTemplate = filePictureUpload;
if (inputSource instanceof String) {
pictureUploadTemplate = urlPictureUpload;
}
UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
// 构造要入库的图片信息
// ...
}